mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
docs: clarify long-audio segmentation, memory and acceptance (#3683)
docs: clarify long-audio segmentation and acceptance Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"blog/funasr-llama-cpp-whisper-cpp-alternative.html": "3e1a8901c3f7f74e310b8050e2f6722c736ce0b37155805b85786d5b5455ae8f",
|
||||
"blog/funasr-realtime-streaming-asr.html": "7b6c34ace6c4c782f5af9c3f52181c1b1ef08d32c4ea3fafab63b1f30a9e72db",
|
||||
"blog/funasr-speaker-diarization.html": "451273993769f38de014160788581529788db8e4ca9fb56773d7723c51d96a52",
|
||||
"blog/funasr-transcribe-long-audio.html": "4f7f593c114dd54b11e3cf2b2e7ddcfea6af29326034ce4a76c690d30f41ff28",
|
||||
"blog/funasr-transcribe-long-audio.html": "3cb00a3b23eeb5990b639fde779ef738a58a08af8976f86a52860ac052537160",
|
||||
"blog/funasr-v1-3-26-openai-vllm-llama-cpp.html": "304453b66c09d65aaa35dfe679c7368c6c69d65ab68bae7d85c536aadecf44b1",
|
||||
"blog/funasr-v1-3-27-language-metadata-vllm-fallback.html": "f1dbbc70fdd630c8a3953bda3b3a1697412b3c4bc25fc3969385583cd47874d2",
|
||||
"blog/funasr-v1-3-28-realtime-websocket-subtitles.html": "46a8d4c310e7f76da632d558d057484f48b345d9616873e1416fc68fcb9e9633",
|
||||
@@ -24,7 +24,7 @@
|
||||
"blog/funclip-v2-1-0-video-clipping-release.html": "88f6c44e5332d1746c4db0fc97d755ef82f12e46d8f1c1c0ad9351152ff9dfc1",
|
||||
"blog/funclip-v2-2-0-moss-speaker-clipping.html": "0d50747d3992301fb3062ad2cdf903bd427bec3db1f1e4fab1f06de207928bc9",
|
||||
"blog/generate-subtitles-srt-vtt-from-audio-video.html": "f1133235673d441cc654310581f59a319f182314ab7c0b0824af87da9f4a0591",
|
||||
"blog/index.html": "c3edf2411f377beb2bf01a0486349b189b1548f6babfc4794c16ab43f49754b4",
|
||||
"blog/index.html": "f966d5ad5c713a4180df05ff84216fd5ad65f831c57968b9feb7e3f4712bc136",
|
||||
"blog/japanese-speech-recognition.html": "399f5ce84e68ac00854bdf70b52b1cde6795efca7c800fd6492035a37a7c1b68",
|
||||
"blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281",
|
||||
"blog/meeting-transcript-acceptance.html": "3e35d1f283c7005977ad8cbd079e38c51e14b51f04f50cedb5636e2d4baf38bd",
|
||||
@@ -53,7 +53,7 @@
|
||||
"en/blog/funasr-llama-cpp-whisper-cpp-alternative.html": "3808ee7a014f63e3befd83c886eca3fd96abea7d4de68a90a8bd23befe579282",
|
||||
"en/blog/funasr-realtime-streaming-asr.html": "65c7353fcdc393fc0adec3969d9762edc8f30dc15d20e360a2b11508d8c60a91",
|
||||
"en/blog/funasr-speaker-diarization.html": "db6511de2f356b7bf5e3f1f89b33991500a293c9d3bd2420d8a6cd6cf6cf3997",
|
||||
"en/blog/funasr-transcribe-long-audio.html": "6d486c9cee4a8c339e09e60ecee579b7cd650ce512d1ac8fe25789e2489b87fb",
|
||||
"en/blog/funasr-transcribe-long-audio.html": "cb9b03a87487b3c551db030f66c16b8a55c042977d5d96cf6012af4cb4a1e273",
|
||||
"en/blog/funasr-v1-3-26-openai-vllm-llama-cpp.html": "665c9c66c9af0e649cc3020859e3af65f6ec595dfdbf36923058b97dc5e2512e",
|
||||
"en/blog/funasr-v1-3-27-language-metadata-vllm-fallback.html": "ffaca54d75c561bda82b6063b27f2a81d810f4f0e89030b6be7a64696ada44b4",
|
||||
"en/blog/funasr-v1-3-28-realtime-websocket-subtitles.html": "233534cb4306387f1a9a695886a5f515679dff591d04b90827f41a668d7f7ada",
|
||||
@@ -66,7 +66,7 @@
|
||||
"en/blog/funclip-v2-1-0-video-clipping-release.html": "229baf59adf2c3290541d9b3c8a6243992406ba84b712714e9d14599205cb1d4",
|
||||
"en/blog/funclip-v2-2-0-moss-speaker-clipping.html": "644159267f0fa4e7aab9d0eda98f68cd2b6365a9985038b639c626a5ea5ea7e8",
|
||||
"en/blog/generate-subtitles-srt-vtt-from-audio-video.html": "1530d4b9e94820a0b60d801e8d1c19b7aeb031b2f2b76c092657392e47706c6d",
|
||||
"en/blog/index.html": "8fccbb7bf21902521cfe78cdf054abf75c5b6fe7ed571f25bfe4a04468c565f8",
|
||||
"en/blog/index.html": "363b5b77b4702ed1f5d7f62fa2eeadb7e4bfa1a56b86ca9c22527d6c7666bec7",
|
||||
"en/blog/japanese-speech-recognition.html": "c476adcc2be1ed7c19e2345cc91b8ee6a0e04efd794b9b6476dd5d9b3a2c8b04",
|
||||
"en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7",
|
||||
"en/blog/meeting-transcript-acceptance.html": "3183682d2cae0ce18f68e3bd33e22b9effb4de699f6a4d821938cc03dd6835bf",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>用 FunASR 转写超长音频:1 小时录音也能一次搞定 | FunASR</title><meta name="description" content="Whisper 一次只处理 30 秒,转写 1 小时音频要自己切片再拼接。FunASR 内置 VAD,一次 generate 调用直接吃下任意时长音频:实测 13.2 分钟录音 4.3 秒转完(186x 实时)。附可运行代码与长音频最佳实践。"><meta name="keywords" content="转写长音频, transcribe long audio, 长音频语音识别, whisper 30秒限制, 一小时音频转文字, 播客转写, 会议录音转文字, FunASR, 音频转文字, long audio transcription">
|
||||
<link rel="icon" href="/logo.png"><meta property="og:title" content="用 FunASR 转写超长音频:1 小时录音也能一次搞定"><meta property="og:description" content="Whisper 一次只处理 30 秒,转写 1 小时音频要自己切片再拼接。FunASR 内置 VAD,一次 generate 调用直接吃下任意时长音频:实测 13.2 分钟录音 4.3 秒转完(186x 实时)。附可运行代码与长音频最佳实践。">
|
||||
<title>长录音转写:VAD 分段、批处理与结果验收 | FunASR</title><meta name="description" content="以 FunASR 1.4.15 的 SenseVoice + FSMN-VAD 离线路径为例,解释完整音频加载、批处理预算与 CPU 行为,区分 Whisper 解码窗口和文件长度,并给出长录音上线验收清单。"><meta name="keywords" content="长录音转写, VAD 分段, SenseVoice, FSMN-VAD, FunASR, 内存预算, 批处理, 会议转写验收">
|
||||
<link rel="icon" href="/logo.png"><meta property="og:title" content="长录音转写:VAD 分段、批处理与结果验收"><meta property="og:description" content="以 FunASR 1.4.15 的 SenseVoice + FSMN-VAD 离线路径为例,解释完整音频加载、批处理预算与 CPU 行为,区分 Whisper 解码窗口和文件长度,并给出长录音上线验收清单。">
|
||||
<meta property="og:type" content="article"><meta property="og:url" content="https://www.funasr.com/blog/funasr-transcribe-long-audio.html"><meta property="og:image" content="https://www.funasr.com/logo.png">
|
||||
<link rel="canonical" href="https://www.funasr.com/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="zh" href="https://www.funasr.com/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="x-default" href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"BlogPosting","headline":"用 FunASR 转写超长音频:1 小时录音也能一次搞定","description":"Whisper 一次只处理 30 秒,转写 1 小时音频要自己切片再拼接。FunASR 内置 VAD,一次 generate 调用直接吃下任意时长音频:实测 13.2 分钟录音 4.3 秒转完(186x 实时)。附可运行代码与长音频最佳实践。","datePublished":"2026-06-17","dateModified":"2026-06-17","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/blog/funasr-transcribe-long-audio.html"},"image":"https://www.funasr.com/logo.png"}
|
||||
{"@context":"https://schema.org","@type":"BlogPosting","headline":"长录音转写:VAD 分段、批处理与结果验收","description":"以 FunASR 1.4.15 的 SenseVoice + FSMN-VAD 离线路径为例,解释完整音频加载、批处理预算与 CPU 行为,区分 Whisper 解码窗口和文件长度,并给出长录音上线验收清单。","datePublished":"2026-06-17","dateModified":"2026-09-09","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/blog/funasr-transcribe-long-audio.html"},"image":"https://www.funasr.com/logo.png"}
|
||||
</script>
|
||||
<style>:root{--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#fff;--surface:#f8fafc;--border:#e2e8f0;--text:#0f172a;--text-soft:#475569;--text-muted:#94a3b8;--radius:12px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
@@ -45,51 +45,79 @@ footer{background:#0f172a;color:#94a3b8;padding:28px 0;text-align:center;font-si
|
||||
footer a{color:#94a3b8}</style></head><body>
|
||||
<nav class="nav"><div class="container"><a href="/" class="nav-logo">Fun<span>ASR</span></a><div class="nav-links"><a href="/#capabilities">核心能力</a><a href="/#usage">如何使用</a><a href="/#benchmark">性能</a><a href="/#demo">演示</a><a href="https://modelscope.github.io/FunASR/zh/" target="_blank">文档</a><a href="/quickstart.html">快速上手</a><a href="/models.html">模型</a><a href="/ecosystem.html">生态</a><a href="/blog/">技术博客</a><a href="/vs-whisper.html">对比 Whisper</a><a href="/donors.html">功德榜</a></div><a href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html" style="font-size:0.83rem;font-weight:600;color:var(--text-soft)">EN</a><a href="https://github.com/modelscope/FunASR" target="_blank" class="nav-btn">GitHub ★</a></div></nav>
|
||||
<article><div class="container">
|
||||
<h1>用 FunASR 转写超长音频:1 小时录音也能一次搞定</h1>
|
||||
<p class="post-meta">FunASR 技术博客 · 2026-06-17 · 教程</p>
|
||||
<p>转写一段 1 小时的播客、讲座或会议录音,用 Whisper 会很痛苦:它一次只处理 <strong>30 秒</strong>,你得自己把音频切片、逐片识别、再把结果拼起来,还要处理切片边界把词切断的问题。</p>
|
||||
<p><strong>FunASR 不用这么麻烦。</strong>它内置 VAD(语音端点检测),一次 <code>generate()</code> 调用就能吃下<strong>任意时长</strong>的音频——自动切句、批量解码、拼回完整文本,你一行切片代码都不用写。</p>
|
||||
<h2>安装</h2>
|
||||
<pre><code>pip install -U funasr modelscope</code></pre>
|
||||
<h2>完整代码(可直接运行)</h2>
|
||||
<pre><code>from funasr import AutoModel
|
||||
<h1>长录音转写:VAD 分段、批处理与结果验收</h1>
|
||||
<p class="post-meta">FunASR 技术博客 · 2026-06-17 · 更新于 2026-09-09</p>
|
||||
<p>播客、课程和会议录音的难点,不只是把文件交给模型,还包括资源预算、停顿与重叠语音、以及末尾是否漏识别。本文以 <strong>FunASR 1.4.15 的离线 SenseVoice + FSMN-VAD 路径</strong>为例,说明一次文件级调用内部做了什么,以及上线前该检查什么。</p>
|
||||
<aside class="limitation-callout" data-long-audio-contract="window">
|
||||
<p><strong>先区分窗口、文件和服务。</strong>开源 Whisper 的 <code>transcribe()</code> 会在内部循环处理约 30 秒的解码窗口;这不是文件长度上限,用户不必为这个入口手工逐片拼接。低层 <code>decode()</code>、文件级转写和托管 API 不是同一契约。参见<a href="https://github.com/openai/whisper/blob/86098128c0b4f24f0e2aa2994de830614b474227/README.md#L115">官方文件转写说明</a>和<a href="https://github.com/openai/whisper/blob/86098128c0b4f24f0e2aa2994de830614b474227/whisper/transcribe.py#L272">固定版本窗口循环源码</a>;本文不讨论云服务当前上传配额。</p>
|
||||
<p>FunASR 在显式配置 VAD 模型时,可以由一次 <code>generate()</code> 调用组织分段和识别。但“一次调用”不等于无限时长、固定内存、完整无漏字或实时流式处理。</p>
|
||||
</aside>
|
||||
<h2>用一个明确的离线配置开始</h2>
|
||||
<section data-long-audio-contract="prerequisites">
|
||||
<p>前提是已经准备好的独立 FunASR 1.4.15 环境,包括 PyTorch、可用的音频解码依赖和模型权重访问。先按<a href="/docs/installation.html">安装与环境验证指南</a>准备平台依赖,再核对<a href="/docs/python-api.html">Python SDK 指南</a>。下方版本检查只检查已安装包的元数据,不是完整安装或声学推理证明。</p>
|
||||
<p>把你有权处理的本地文件放在 <code>meeting.wav</code>。先用短录音验证环境,再逐步测试业务长录音;首次加载可能联网下载模型。输出包含转写文本,不要把私人内容放进公开日志。代码固定工具包版本,但未锁定全部依赖和模型 revision,正式部署应另行记录并固定它们。</p>
|
||||
</section>
|
||||
<pre data-long-audio-contract="recipe"><code>from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
|
||||
installed = version("funasr")
|
||||
if installed != "1.4.15":
|
||||
raise RuntimeError(f"Expected FunASR 1.4.15, found {installed}")
|
||||
|
||||
audio = Path("meeting.wav")
|
||||
if not audio.is_file():
|
||||
raise FileNotFoundError(audio)
|
||||
|
||||
from funasr import AutoModel
|
||||
|
||||
model = AutoModel(
|
||||
model="iic/SenseVoiceSmall", # or "paraformer-zh"
|
||||
vad_model="fsmn-vad", # built-in voice activity detection
|
||||
model="iic/SenseVoiceSmall",
|
||||
vad_model="fsmn-vad",
|
||||
vad_kwargs={"max_single_segment_time": 30000},
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
# One call on a full 1-hour file - VAD segments and batches it internally.
|
||||
res = model.generate(
|
||||
input="podcast_1hour.wav",
|
||||
batch_size_s=300, # dynamic batching of VAD segments (throughput)
|
||||
result = model.generate(
|
||||
input=str(audio),
|
||||
batch_size_s=300,
|
||||
)
|
||||
print(res[0]["text"])</code></pre>
|
||||
<h2>实测:13 分钟音频 4.3 秒转完</h2>
|
||||
<pre><code>long clip: 791s = 13.2 min
|
||||
transcribed 791s in 4.3s -> RTFx=186
|
||||
output chars: 2104 # full transcript, head to tail</code></pre>
|
||||
<p>我们把一段 <strong>13.2 分钟(791 秒)</strong>的录音丢进单次 <code>generate()</code>,<strong>4.3 秒</strong>就转写完毕(<strong>186x 实时</strong>),输出完整覆盖从开头到结尾。换成 1 小时的文件,做法完全一样——不用改一行代码。</p>
|
||||
<h2>为什么能处理任意时长</h2>
|
||||
if not result or not isinstance(result[0], dict):
|
||||
raise RuntimeError("No result record; inspect audio and model logs")
|
||||
text = result[0].get("text", "")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise RuntimeError("Empty transcript; inspect audio, silence and VAD output")
|
||||
print(text)</code></pre>
|
||||
<p>这里明确使用 CPU,按 VAD 片段逐个处理;<code>batch_size_s</code> 不是 CPU 并行参数,也不是速度推荐。空结果需要检查:可能是静音、VAD 未选中语音、输入问题或推理异常,不能一律当成成功或模型错误。示例故意停止并要求人工检查,不提供伪造的转写输出。</p>
|
||||
<h2>一次调用背后的资源路径</h2>
|
||||
<section data-long-audio-contract="resources">
|
||||
<p>这条离线路径会读取完整音频波形到 CPU 内存,再使用 VAD 区域组织 ASR 输入;它不是只从磁盘读取当前片段的有界内存流式处理。分段可以改变某一批推理的工作量,但整段波形、解码缓冲、分段信息、输出和并发请求仍占用资源。</p>
|
||||
<p>仅按 16 kHz、单声道、float32 波形计算,一小时数据约 230 MB;这只是数据量估算,不是进程 RAM 或 GPU 峰值内存实测,也不包含解码副本和模型。不能据此承诺任意文件都能处理,或一小时与一分钟文件显存相同。可接受的时长需要在所选硬件、模型、音频格式和并发条件下验证。</p>
|
||||
<p>查看<a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L920">整段加载与分段排序</a>、<a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/utils/load_utils.py#L128">音频解码入口</a>。这些源码能说明数据路径,不能替代峰值内存测量。</p>
|
||||
</section>
|
||||
<h2>两个参数,两个不同的边界</h2>
|
||||
<section data-long-audio-contract="batching">
|
||||
<table><thead><tr><th>参数</th><th>含义</th><th>不能保证什么</th></tr></thead><tbody>
|
||||
<tr><td><code>max_single_segment_time=30000</code></td><td>FSMN-VAD 的端点阈值,单位毫秒(ms)。它参与帧级语音分段决策。</td><td>不是整文件时长限制,不保证严格等长片段或恰好切在一个词、句子的边界。</td></tr>
|
||||
<tr><td><code>batch_size_s=300</code></td><td>VAD 包装器的批预算,单位秒(s),内部转为毫秒;组批判据涉及最长片段时长乘以片段数量。</td><td>不是所有片段时长简单求和,也不是硬内存上限;较长的单段仍可能单独进入推理。</td></tr>
|
||||
</tbody></table>
|
||||
<p><strong>CPU 例外:</strong>当前包装器在 <code>device="cpu"</code> 时关闭上述组批预算,按片段逐个处理。因此调整示例中的 <code>batch_size_s</code> 不能当作 CPU 批吞吐优化已经生效。换用经过配置和验证的 GPU 环境时,应重新测量,而不是套用相同内存或速度结论。实现依据:<a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L899">批预算单位</a>、<a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L935">CPU 分支与组批判据</a>。</p>
|
||||
</section>
|
||||
<h2>长文件上线前怎么验收</h2>
|
||||
<section data-long-audio-contract="acceptance">
|
||||
<ol>
|
||||
<li><strong>先验证输入:</strong>检查文件可解码、实际时长、声道和采样率。不要把读取成功或非空文本视为完整处理证明。</li>
|
||||
<li><strong>覆盖业务难点:</strong>准备短录音、长静音、多人重叠、连续长发言,以及末尾有明确语音的样本。重点复听切分处、关键实体和尾部;时间戳空隙也可能是静音。</li>
|
||||
<li><strong>记录资源和时延:</strong>固定模型与依赖版本、硬件、设备、音频身份和并发;分开记录初始化与文件调用耗时,说明是否预热,测量 RAM 和 GPU 峰值,保留失败与空结果。</li>
|
||||
<li><strong>再扩大负载:</strong>逐步增加录音长度和并发,确定服务的准入、超时、队列与重试策略。一次成功不能证明所有时长都可用;客户端超时也不等于后台推理被取消。</li>
|
||||
</ol>
|
||||
<p>更多思路见<a href="/blog/meeting-transcript-acceptance.html">会议转写验收清单</a>与<a href="/docs/benchmark-method.html">可复现性能评测</a>。前者附带的 MOSS <code>sentence_info</code> 检查器有特定输入契约,不可直接套在任意 SenseVoice 返回值上;结构检查也不证明文字准确率或语音覆盖完整。</p>
|
||||
</section>
|
||||
<h2>模型和服务应分开选择</h2>
|
||||
<ul>
|
||||
<li><strong>VAD 自动切句</strong>:<code>fsmn-vad</code> 先把长音频按停顿切成一句句语音段,<code>max_single_segment_time</code> 控制单段最长时长。</li>
|
||||
<li><strong>动态批处理</strong>:<code>batch_size_s=300</code> 把多段语音按总时长打包成批,一次性高吞吐解码。</li>
|
||||
<li><strong>内存可控</strong>:因为是分段处理,显存占用<strong>与文件总长无关</strong>,1 小时和 1 分钟占用相近。</li>
|
||||
<li>本文是明确配置外部 FSMN-VAD 的 SenseVoice 离线示例。Paraformer 的配置与输出应另行核对;SenseVoice 富文本标签不是经过验证的情绪准确率。</li>
|
||||
<li>第三方 <a href="/docs/moss-transcribe-diarize.html">MOSS-Transcribe-Diarize</a> 有自己的整录音转写与原生说话人分离路径,不需要照搬本文的外部 VAD 或 CAM++。匿名说话人标签不等于实名身份识别。</li>
|
||||
<li>需要实时麦克风转写时,按<a href="/deploy/">部署矩阵</a>选择明确支持的流式模型与服务;缩小离线批次并不会把它变成流式协议。更多能力差异见<a href="/docs/model-selection.html">模型选型指南</a>。</li>
|
||||
</ul>
|
||||
<h2>进阶</h2>
|
||||
<ul>
|
||||
<li>加 <code>punc_model="ct-punc"</code> 自动补标点;</li>
|
||||
<li>加 <code>spk_model="cam++"</code> 得到<a href="/blog/funasr-speaker-diarization.html">说话人分离</a>(谁在什么时候说);</li>
|
||||
<li>用 <code>SenseVoiceSmall</code> 可同时拿到<a href="/blog/sensevoice-emotion-language-detection.html">语种与情感</a>;追求最快可看 <a href="/blog/funasr-vs-whisper-benchmark.html">速度对比</a>。</li>
|
||||
</ul>
|
||||
<h2>典型场景</h2>
|
||||
<ul>
|
||||
<li><strong>播客 / 有声书</strong>:整集一次转写、生成文稿。</li>
|
||||
<li><strong>会议 / 讲座 / 庭审</strong>:长录音批量转文字 + 存档检索。</li>
|
||||
<li><strong>客服通话</strong>:整通录音转写做质检与挖掘。</li>
|
||||
</ul>
|
||||
<div class="cta"><p>FunASR 是通义实验室开源的工业级语音识别工具包,长音频、流式、多语种全覆盖。</p><a class="btn" href="https://github.com/modelscope/FunASR" target="_blank">在 GitHub 上 Star FunASR ★</a></div>
|
||||
<p data-long-audio-contract="evidence">本文依据固定版本源码解释行为,没有新增长录音性能或准确率实测。旧页缺乏完整复现条件的耗时和全程覆盖宣传已撤下;历史版本保留在仓库中,不能作为当前部署承诺。</p>
|
||||
<!--related-posts-->
|
||||
<h2>相关文章</h2>
|
||||
<ul class="post-list">
|
||||
@@ -118,4 +146,4 @@ output chars: 2104 # full transcript, head to tail</code></pre>
|
||||
</ul>
|
||||
</div></article>
|
||||
<footer><div class="container">FunASR · 通义实验室开源语音识别 · <a href="https://github.com/modelscope/FunASR">GitHub</a></div></footer>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Transcribe Long Audio with FunASR: Hours in a Single Call | FunASR</title><meta name="description" content="Whisper processes only 30s at a time, so a 1-hour file means manual chunking and stitching. FunASR has built-in VAD: a single generate() call ingests audio of any length - tested 13.2 min transcribed in 4.3s (186x realtime). Runnable code + long-audio best practices."><meta name="keywords" content="transcribe long audio, long audio transcription, whisper 30 second limit, transcribe 1 hour audio, podcast transcription, meeting transcription, FunASR, audio to text python">
|
||||
<link rel="icon" href="/logo.png"><meta property="og:title" content="Transcribe Long Audio with FunASR: Hours in a Single Call"><meta property="og:description" content="Whisper processes only 30s at a time, so a 1-hour file means manual chunking and stitching. FunASR has built-in VAD: a single generate() call ingests audio of any length - tested 13.2 min transcribed in 4.3s (186x realtime). Runnable code + long-audio best practices.">
|
||||
<title>Long-File Transcription: VAD Segmentation, Batching and Acceptance | FunASR</title><meta name="description" content="Understand full-file loading, VAD batch budgets and CPU behavior in FunASR 1.4.15's offline SenseVoice path. Separate Whisper decoding windows from file limits and prepare a long-recording acceptance checklist."><meta name="keywords" content="long audio transcription, VAD segmentation, SenseVoice, FSMN-VAD, FunASR, memory budget, batching, transcript acceptance">
|
||||
<link rel="icon" href="/logo.png"><meta property="og:title" content="Long-File Transcription: VAD Segmentation, Batching and Acceptance"><meta property="og:description" content="Understand full-file loading, VAD batch budgets and CPU behavior in FunASR 1.4.15's offline SenseVoice path. Separate Whisper decoding windows from file limits and prepare a long-recording acceptance checklist.">
|
||||
<meta property="og:type" content="article"><meta property="og:url" content="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"><meta property="og:image" content="https://www.funasr.com/logo.png">
|
||||
<link rel="canonical" href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="zh" href="https://www.funasr.com/blog/funasr-transcribe-long-audio.html"><link rel="alternate" hreflang="x-default" href="https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"BlogPosting","headline":"Transcribe Long Audio with FunASR: Hours in a Single Call","description":"Whisper processes only 30s at a time, so a 1-hour file means manual chunking and stitching. FunASR has built-in VAD: a single generate() call ingests audio of any length - tested 13.2 min transcribed in 4.3s (186x realtime). Runnable code + long-audio best practices.","datePublished":"2026-06-17","dateModified":"2026-06-17","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"},"image":"https://www.funasr.com/logo.png"}
|
||||
{"@context":"https://schema.org","@type":"BlogPosting","headline":"Long-File Transcription: VAD Segmentation, Batching and Acceptance","description":"Understand full-file loading, VAD batch budgets and CPU behavior in FunASR 1.4.15's offline SenseVoice path. Separate Whisper decoding windows from file limits and prepare a long-recording acceptance checklist.","datePublished":"2026-06-17","dateModified":"2026-09-09","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/en/blog/funasr-transcribe-long-audio.html"},"image":"https://www.funasr.com/logo.png"}
|
||||
</script>
|
||||
<style>:root{--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#fff;--surface:#f8fafc;--border:#e2e8f0;--text:#0f172a;--text-soft:#475569;--text-muted:#94a3b8;--radius:12px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
@@ -45,51 +45,79 @@ footer{background:#0f172a;color:#94a3b8;padding:28px 0;text-align:center;font-si
|
||||
footer a{color:#94a3b8}</style></head><body>
|
||||
<nav class="nav"><div class="container"><a href="/" class="nav-logo">Fun<span>ASR</span></a><div class="nav-links"><a href="/en/#capabilities">Capabilities</a><a href="/en/#usage">How to Use</a><a href="/en/#benchmark">Benchmark</a><a href="/en/#demo">Demo</a><a href="https://modelscope.github.io/FunASR/" target="_blank">Docs</a><a href="/en/quickstart.html">Quickstart</a><a href="/en/models.html">Models</a><a href="/en/ecosystem.html">Ecosystem</a><a href="/en/blog/">Blog</a><a href="/en/vs-whisper.html">vs Whisper</a><a href="/en/donors.html">Donors</a></div><a href="https://www.funasr.com/blog/funasr-transcribe-long-audio.html" style="font-size:0.83rem;font-weight:600;color:var(--text-soft)">中文</a><a href="https://github.com/modelscope/FunASR" target="_blank" class="nav-btn">GitHub ★</a></div></nav>
|
||||
<article><div class="container">
|
||||
<h1>Transcribe Long Audio with FunASR: Hours in a Single Call</h1>
|
||||
<p class="post-meta">FunASR Blog · 2026-06-17 · Tutorial</p>
|
||||
<p>Transcribing a 1-hour podcast, lecture or meeting with Whisper is painful: it processes only <strong>30 seconds</strong> at a time, so you have to chunk the audio, transcribe each piece, and stitch the results back together - while handling words cut off at chunk boundaries.</p>
|
||||
<p><strong>FunASR makes this trivial.</strong> With built-in VAD (voice activity detection), a single <code>generate()</code> call ingests audio of <strong>any length</strong> - it segments, batch-decodes and reassembles the full text for you, with zero chunking code.</p>
|
||||
<h2>Install</h2>
|
||||
<pre><code>pip install -U funasr modelscope</code></pre>
|
||||
<h2>Full, runnable code</h2>
|
||||
<pre><code>from funasr import AutoModel
|
||||
<h1>Long-File Transcription: VAD Segmentation, Batching and Acceptance</h1>
|
||||
<p class="post-meta">FunASR Blog · 2026-06-17 · Updated 2026-09-09</p>
|
||||
<p>Podcasts, courses and meeting recordings require more than passing a file to a model: resource budgets, pauses, overlapping speech and missing tail speech all matter. This guide uses the <strong>offline SenseVoice + FSMN-VAD path in FunASR 1.4.15</strong> to explain what happens inside a file-level call and what to check before rollout.</p>
|
||||
<aside class="limitation-callout" data-long-audio-contract="window">
|
||||
<p><strong>Separate windows, files and services.</strong> Open-source Whisper's <code>transcribe()</code> loops over roughly 30-second decoding windows internally. That is not a file-duration limit, and this entry point does not require users to split and stitch every window themselves. Low-level <code>decode()</code>, file transcription and hosted APIs have different contracts. See the <a href="https://github.com/openai/whisper/blob/86098128c0b4f24f0e2aa2994de830614b474227/README.md#L115">official file-transcription guide</a> and <a href="https://github.com/openai/whisper/blob/86098128c0b4f24f0e2aa2994de830614b474227/whisper/transcribe.py#L272">pinned window-loop implementation</a>. This article makes no claim about current cloud upload quotas.</p>
|
||||
<p>With an explicitly configured VAD model, FunASR can organize segmentation and recognition inside one <code>generate()</code> call. One call does not imply unlimited duration, fixed memory, complete recognition or realtime streaming.</p>
|
||||
</aside>
|
||||
<h2>Start with one explicit offline configuration</h2>
|
||||
<section data-long-audio-contract="prerequisites">
|
||||
<p>Use an already prepared, separate FunASR 1.4.15 environment with PyTorch, working audio-decoding dependencies and access to model weights. Follow the <a href="/en/docs/installation.html">installation and environment guide</a> for platform preparation, then check the <a href="/en/docs/python-api.html">Python SDK guide</a>. The version check below inspects installed package metadata; it is not proof of a complete installation or acoustic inference.</p>
|
||||
<p>Place a local file you are authorized to process at <code>meeting.wav</code>. Validate the environment with a short recording before moving to longer business recordings; initial loading may download models. The output contains transcript text, which should stay out of public logs. The recipe pins the toolkit version, not every dependency or model revision; record and pin those separately for deployment.</p>
|
||||
</section>
|
||||
<pre data-long-audio-contract="recipe"><code>from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
|
||||
installed = version("funasr")
|
||||
if installed != "1.4.15":
|
||||
raise RuntimeError(f"Expected FunASR 1.4.15, found {installed}")
|
||||
|
||||
audio = Path("meeting.wav")
|
||||
if not audio.is_file():
|
||||
raise FileNotFoundError(audio)
|
||||
|
||||
from funasr import AutoModel
|
||||
|
||||
model = AutoModel(
|
||||
model="iic/SenseVoiceSmall", # or "paraformer-zh"
|
||||
vad_model="fsmn-vad", # built-in voice activity detection
|
||||
model="iic/SenseVoiceSmall",
|
||||
vad_model="fsmn-vad",
|
||||
vad_kwargs={"max_single_segment_time": 30000},
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
# One call on a full 1-hour file - VAD segments and batches it internally.
|
||||
res = model.generate(
|
||||
input="podcast_1hour.wav",
|
||||
batch_size_s=300, # dynamic batching of VAD segments (throughput)
|
||||
result = model.generate(
|
||||
input=str(audio),
|
||||
batch_size_s=300,
|
||||
)
|
||||
print(res[0]["text"])</code></pre>
|
||||
<h2>Tested: 13 minutes transcribed in 4.3s</h2>
|
||||
<pre><code>long clip: 791s = 13.2 min
|
||||
transcribed 791s in 4.3s -> RTFx=186
|
||||
output chars: 2104 # full transcript, head to tail</code></pre>
|
||||
<p>We fed a <strong>13.2-minute (791s)</strong> recording into a single <code>generate()</code> call - done in <strong>4.3s (186x realtime)</strong>, with output covering the full file head to tail. A 1-hour file works exactly the same way, no code changes.</p>
|
||||
<h2>Why it handles any length</h2>
|
||||
if not result or not isinstance(result[0], dict):
|
||||
raise RuntimeError("No result record; inspect audio and model logs")
|
||||
text = result[0].get("text", "")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise RuntimeError("Empty transcript; inspect audio, silence and VAD output")
|
||||
print(text)</code></pre>
|
||||
<p>This explicit CPU path handles VAD regions individually; <code>batch_size_s</code> is not a CPU parallelism control or a speed recommendation. Empty output needs inspection: silence, VAD selection, input problems or inference errors are possible causes. It is not automatically success or a model defect. The example deliberately stops for inspection and supplies no fabricated transcript output.</p>
|
||||
<h2>The resource path inside a file call</h2>
|
||||
<section data-long-audio-contract="resources">
|
||||
<p>This offline path loads the complete audio waveform into CPU memory, then organizes ASR inputs using VAD regions. It is not a bounded-memory stream that reads only the current segment from disk. Segmentation can change the work in one inference batch, but the full waveform, decoding buffers, segment metadata, outputs and concurrent requests still consume resources.</p>
|
||||
<p>For a 16 kHz, mono, float32 waveform alone, one hour is about 230 MB. This is a data-size estimate, not measured peak process RAM or GPU memory, and excludes decoder copies and model state. It cannot establish support for every file or equal GPU memory for one-hour and one-minute inputs. Validate acceptable durations on the chosen hardware, model, audio format and concurrency.</p>
|
||||
<p>See <a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L920">whole-file loading and segment sorting</a> and the <a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/utils/load_utils.py#L128">audio-decoding entry point</a>. Source inspection explains the data path; it does not replace peak-memory measurement.</p>
|
||||
</section>
|
||||
<h2>Two parameters with different boundaries</h2>
|
||||
<section data-long-audio-contract="batching">
|
||||
<table><thead><tr><th>Parameter</th><th>Meaning</th><th>What it does not guarantee</th></tr></thead><tbody>
|
||||
<tr><td><code>max_single_segment_time=30000</code></td><td>An FSMN-VAD endpoint threshold in milliseconds (ms), used in frame-level speech segmentation decisions.</td><td>It is not a file-duration limit or a guarantee of exactly equal segments or cuts at word and sentence boundaries.</td></tr>
|
||||
<tr><td><code>batch_size_s=300</code></td><td>A VAD-wrapper batch budget in seconds (s), converted to milliseconds internally. Grouping considers the longest segment duration multiplied by the number of segments.</td><td>It is not simply the sum of segment durations or a hard memory cap; a longer segment may still be processed alone.</td></tr>
|
||||
</tbody></table>
|
||||
<p><strong>CPU exception:</strong> the current wrapper disables that grouping budget for <code>device="cpu"</code> and handles segments individually. Changing <code>batch_size_s</code> in this example is therefore not evidence of CPU batch-throughput optimization. A prepared and validated GPU setup needs new measurements, not inherited memory or speed conclusions. See <a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L899">budget units</a> and the <a href="https://github.com/modelscope/FunASR/blob/5be786088ea6ea9db5f1abf18a6c16e550760e10/funasr/auto/auto_model.py#L935">CPU branch and grouping rule</a>.</p>
|
||||
</section>
|
||||
<h2>Acceptance before admitting long recordings</h2>
|
||||
<section data-long-audio-contract="acceptance">
|
||||
<ol>
|
||||
<li><strong>Verify inputs:</strong> check decodability, actual duration, channels and sample rate. A successful read or nonempty transcript is not completeness evidence.</li>
|
||||
<li><strong>Cover business difficulties:</strong> include short recordings, long silences, overlapping speakers, sustained speech and a clear spoken tail. Listen around cuts, key entities and the end. A timestamp gap may also be silence.</li>
|
||||
<li><strong>Record resources and latency:</strong> identify model/dependency versions, hardware, device, audio and concurrency. Separate initialization from file-call timing, state warmup conditions, measure peak RAM and GPU memory, and retain errors and empty results.</li>
|
||||
<li><strong>Increase load gradually:</strong> raise duration and concurrency to establish admission, timeout, queue and retry policies. One successful request does not validate all durations; a client timeout does not prove that backend inference was cancelled.</li>
|
||||
</ol>
|
||||
<p>See the <a href="/en/blog/meeting-transcript-acceptance.html">meeting-transcript acceptance checklist</a> and <a href="/en/docs/benchmark-method.html">reproducible performance guide</a>. The former's MOSS <code>sentence_info</code> audit script has a specific input contract, not an adapter for arbitrary SenseVoice results. Structural checks do not prove text accuracy or complete speech coverage.</p>
|
||||
</section>
|
||||
<h2>Choose models and services separately</h2>
|
||||
<ul>
|
||||
<li><strong>VAD auto-segmentation</strong>: <code>fsmn-vad</code> splits the long audio into speech segments at pauses; <code>max_single_segment_time</code> caps the longest segment.</li>
|
||||
<li><strong>Dynamic batching</strong>: <code>batch_size_s=300</code> packs segments by total duration for high-throughput decoding.</li>
|
||||
<li><strong>Bounded memory</strong>: because it processes segments, GPU memory is <strong>independent of total file length</strong> - 1 hour uses about as much as 1 minute.</li>
|
||||
<li>This is an offline SenseVoice example with explicitly configured external FSMN-VAD. Check Paraformer configuration and outputs separately. SenseVoice rich-text tags are not verified emotion accuracy.</li>
|
||||
<li>Third-party <a href="/en/docs/moss-transcribe-diarize.html">MOSS-Transcribe-Diarize</a> has its own whole-recording transcription and native diarization path. It does not require this external VAD or CAM++ recipe. Anonymous speaker labels are not verified real-world identities.</li>
|
||||
<li>For realtime microphone transcription, select an explicitly supported streaming model and service using the <a href="/en/deploy/">deployment matrix</a>. Smaller offline batches do not create a streaming protocol. See <a href="/en/docs/model-selection.html">model selection</a> for capability differences.</li>
|
||||
</ul>
|
||||
<h2>Going further</h2>
|
||||
<ul>
|
||||
<li>Add <code>punc_model="ct-punc"</code> for automatic punctuation;</li>
|
||||
<li>Add <code>spk_model="cam++"</code> for <a href="/en/blog/funasr-speaker-diarization.html">speaker diarization</a> (who spoke when);</li>
|
||||
<li>Use <code>SenseVoiceSmall</code> for <a href="/en/blog/sensevoice-emotion-language-detection.html">language & emotion</a>; for max speed see the <a href="/en/blog/funasr-vs-whisper-benchmark.html">benchmark</a>.</li>
|
||||
</ul>
|
||||
<h2>Typical use cases</h2>
|
||||
<ul>
|
||||
<li><strong>Podcasts / audiobooks</strong>: transcribe a whole episode at once.</li>
|
||||
<li><strong>Meetings / lectures</strong>: batch long recordings to text + archive/search.</li>
|
||||
<li><strong>Call recordings</strong>: full-call transcription for QA and mining.</li>
|
||||
</ul>
|
||||
<div class="cta"><p>FunASR is Tongyi Lab's open-source, industrial-grade speech recognition toolkit - long audio, streaming and multilingual all covered.</p><a class="btn" href="https://github.com/modelscope/FunASR" target="_blank">Star FunASR on GitHub ★</a></div>
|
||||
<p data-long-audio-contract="evidence">This article explains pinned source behavior, not new long-recording performance or accuracy measurements. The previous page's timing and full-coverage promotion lacked complete reproduction conditions and has been removed. Repository history preserves that text; it is not a current deployment promise.</p>
|
||||
<!--related-posts-->
|
||||
<h2>Related posts</h2>
|
||||
<ul class="post-list">
|
||||
@@ -118,4 +146,4 @@ output chars: 2104 # full transcript, head to tail</code></pre>
|
||||
</ul>
|
||||
</div></article>
|
||||
<footer><div class="container">FunASR · Open-source speech recognition by Tongyi Lab · <a href="https://github.com/modelscope/FunASR">GitHub</a></div></footer>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const prefix of ['', 'en/']) {
|
||||
for (const width of [390, 1440]) {
|
||||
test(`long-audio guide ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const route = `/${prefix}blog/funasr-transcribe-long-audio.html`;
|
||||
await page.goto(`/${prefix}blog/`);
|
||||
await page.locator(`a.post-card[href="${route}"]`).click();
|
||||
await expect(page).toHaveURL(new RegExp(route.replaceAll('.', '\\.')));
|
||||
for (const name of ['window', 'prerequisites', 'recipe', 'resources', 'batching', 'acceptance', 'evidence']) {
|
||||
await expect(page.locator(`[data-long-audio-contract="${name}"]`)).toBeVisible();
|
||||
}
|
||||
const layout = await page.evaluate(() => {
|
||||
const article = document.querySelector('article')!;
|
||||
const box = (selector: string) => article.querySelector(selector)!.getBoundingClientRect();
|
||||
return {
|
||||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
headingTop: box('h1').top,
|
||||
navBottom: document.querySelector('nav')!.getBoundingClientRect().bottom,
|
||||
warningBottom: box('[data-long-audio-contract="window"]').bottom,
|
||||
recipeTop: box('[data-long-audio-contract="recipe"]').top,
|
||||
};
|
||||
});
|
||||
expect(layout.overflow).toBeLessThanOrEqual(1);
|
||||
expect(layout.headingTop).toBeGreaterThanOrEqual(layout.navBottom);
|
||||
expect(layout.warningBottom).toBeLessThanOrEqual(layout.recipeTop);
|
||||
await page.screenshot({ path: testInfo.outputPath('article-top.png') });
|
||||
const table = page.locator('[data-long-audio-contract="batching"] table');
|
||||
await table.scrollIntoViewIfNeeded();
|
||||
const scroll = await table.evaluate((node) => {
|
||||
const wrapper = node.closest('.table-wrap')!;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
wrapper.scrollLeft = wrapper.scrollWidth;
|
||||
return { left: rect.left, right: rect.right, width: wrapper.clientWidth,
|
||||
content: wrapper.scrollWidth, scrolled: wrapper.scrollLeft };
|
||||
});
|
||||
expect(scroll.left).toBeGreaterThanOrEqual(0);
|
||||
expect(scroll.right).toBeLessThanOrEqual(width);
|
||||
if (width === 390) {
|
||||
expect(scroll.content).toBeGreaterThan(scroll.width);
|
||||
expect(scroll.scrolled).toBeGreaterThan(0);
|
||||
expect(scroll.scrolled + scroll.width).toBeGreaterThanOrEqual(scroll.content - 1);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath('batching-table.png') });
|
||||
await page.locator(`article a[href="/${prefix}docs/python-api.html"]`).click();
|
||||
await expect(page.locator('[data-source-link]')).toHaveAttribute(
|
||||
'href', new RegExp(`/docs/python_api${prefix ? '' : '_zh'}\\.md$`),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Long-file documentation contracts, without acoustic models or network access."""
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import importlib.metadata
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
SITE = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SITE))
|
||||
from build import build
|
||||
|
||||
SLUG = "funasr-transcribe-long-audio.html"
|
||||
WHISPER_REVISION = "86098128c0b4f24f0e2aa2994de830614b474227"
|
||||
FALSE_CLAIMS = (
|
||||
r"Whisper\s+(?:caps?\s+at|限)\s*30\s*(?:s\b|seconds?\b|秒)",
|
||||
r"(?:you\s+(?:have|need)\s+to|a\s+1-hour\s+file\s+means)\s+(?:manually\s+)?chunk(?:ing)?",
|
||||
r"你(?:得|必须|需要)自己把音频切片",
|
||||
r"(?:ingests?\s+audio\s+of|handles?)\s+any\s+length",
|
||||
r"(?:吃下|处理)\s*(?:任意|无限)时长",
|
||||
r"(?:GPU\s+memory\s+is\s+)?independent\s+of\s+total\s+file\s+length",
|
||||
r"(?:显存占用\s*)?与文件总长无关",
|
||||
r"1\s*hour\s+uses\s+about\s+as\s+much\s+as\s+1\s*minute",
|
||||
r"1\s*小时和\s*1\s*分钟占用相近",
|
||||
)
|
||||
NEGATION = r"\b(?:not|no|never|cannot|doesn't|isn't)\b|不能|并非|不是|不保证|不代表|不意味着|没有"
|
||||
HISTORICAL_NUMBER = r"\b791\b|\b4\.3\s*(?:s\b|秒)|\b186\s*[x倍]|\b2104\b"
|
||||
MULTIPLICATION = r"multiply|multiplied|multiplication|times|product|×|\*|乘"
|
||||
CPU_PER_SEGMENT = r"per.segment|one.*segment|individual.*segment|segments?\s+individually|逐段|单段|每段|按片段逐个"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=("source", "built"))
|
||||
def site_tree(request, tmp_path_factory):
|
||||
if request.param == "source":
|
||||
return SITE / "legacy"
|
||||
output = tmp_path_factory.mktemp("long-audio-contracts")
|
||||
build(output)
|
||||
return output
|
||||
|
||||
|
||||
def page(root, prefix):
|
||||
return BeautifulSoup((root / prefix / "blog" / SLUG).read_text(), "html.parser")
|
||||
|
||||
|
||||
def visible_text(node):
|
||||
assert node is not None, "Missing visible long-audio explanation"
|
||||
for parent in (node, *node.parents):
|
||||
assert not parent.has_attr("hidden")
|
||||
assert parent.get("aria-hidden") != "true"
|
||||
assert not re.search(r"display\s*:\s*none|visibility\s*:\s*hidden", parent.get("style", ""))
|
||||
text = " ".join(node.get_text(" ", strip=True).split())
|
||||
assert text, "A marker without readable content is not a contract"
|
||||
return text
|
||||
|
||||
|
||||
def contract(soup, name):
|
||||
return soup.select_one(f'article [data-long-audio-contract="{name}"]')
|
||||
|
||||
|
||||
def require_concepts(text, *patterns):
|
||||
for pattern in patterns:
|
||||
assert re.search(pattern, text, re.I), (pattern, text)
|
||||
|
||||
|
||||
def reject_positive_false_claims(text):
|
||||
text = " ".join(text.split())
|
||||
for pattern in FALSE_CLAIMS:
|
||||
for match in re.finditer(pattern, text, re.I):
|
||||
# A negation from a previous sentence/clause must not excuse a new claim.
|
||||
before = re.split(r"[.!?。!?;;]|\bbut\b|\bhowever\b|但是|但", text[:match.start()], flags=re.I)[-1]
|
||||
if re.search(NEGATION, before[-65:], re.I):
|
||||
continue
|
||||
pytest.fail(f"Unqualified long-audio claim: {match.group()}")
|
||||
|
||||
|
||||
def promotional_texts(soup):
|
||||
yield soup.title.get_text() if soup.title else ""
|
||||
yield visible_text(soup.select_one("article h1"))
|
||||
for meta in soup.select('meta[name="description"], meta[name="keywords"], meta[property="og:title"], meta[property="og:description"]'):
|
||||
yield meta.get("content", "")
|
||||
for script in soup.select('script[type="application/ld+json"]'):
|
||||
records = json.loads(script.get_text())
|
||||
for record in records if isinstance(records, list) else [records]:
|
||||
for key in ("headline", "description"):
|
||||
yield record.get(key, "")
|
||||
|
||||
|
||||
def recipe_tree(soup):
|
||||
blocks = [pre for pre in soup.select("article pre")
|
||||
if re.search(r"from\s+funasr\s+import\s+AutoModel", pre.get_text())]
|
||||
assert len(blocks) == 1, "Retain one complete, unambiguous Python SDK recipe"
|
||||
return ast.parse(blocks[0].get_text())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_claims_are_bounded_in_article_seo_and_actual_index_card(site_tree, prefix):
|
||||
soup = page(site_tree, prefix)
|
||||
texts = [*promotional_texts(soup), visible_text(soup.select_one("article"))]
|
||||
index = BeautifulSoup((site_tree / prefix / "blog/index.html").read_text(), "html.parser")
|
||||
cards = index.select(f'a.post-card[href="/{prefix}blog/{SLUG}"]')
|
||||
assert len(cards) == 1
|
||||
texts.append(visible_text(cards[0]))
|
||||
for text in texts:
|
||||
reject_positive_false_claims(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_whisper_window_is_distinguished_from_file_and_hosted_limits(site_tree, prefix):
|
||||
text = visible_text(contract(page(site_tree, prefix), "window"))
|
||||
require_concepts(text, r"Whisper", r"transcribe", r"30[\s-]*(?:second|s\b|秒)",
|
||||
r"internal|internally|内部", r"window|窗口", r"file|文件", NEGATION,
|
||||
r"hosted|cloud|托管|云", r"upload|上传")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_resources_distinguish_full_decode_batches_and_streaming(site_tree, prefix):
|
||||
text = visible_text(contract(page(site_tree, prefix), "resources"))
|
||||
require_concepts(text, r"CPU|RAM|host|主机|内存", r"full|whole|entire|完整|整段|整个",
|
||||
r"decode|waveform|解码|波形")
|
||||
require_concepts(text, r"GPU|VRAM|显存", r"batch|批", r"model|模型", NEGATION,
|
||||
r"stream|流式", r"offline|file|离线|文件",
|
||||
r"duration|length|时长|长度", r"limit|bound|上限|边界|限制|有界")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_batch_and_endpoint_units_match_the_specific_cpu_recipe(site_tree, prefix):
|
||||
text = visible_text(contract(page(site_tree, prefix), "batching"))
|
||||
require_concepts(text, r"max_single_segment_time", r"30000|30,000",
|
||||
r"millisecond|\bms\b|毫秒", r"threshold|端点|阈值", NEGATION,
|
||||
r"sentence|phoneme|linguistic|句|音素|语言")
|
||||
require_concepts(text, r"batch_size_s", r"\b300\b", r"second|秒",
|
||||
r"longest|max|最长", r"count|number|数量|段数", MULTIPLICATION)
|
||||
require_concepts(text, r"CPU", CPU_PER_SEGMENT,
|
||||
r"throughput|performance|吞吐|性能", NEGATION)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_prepared_sdk_environment_is_not_an_install_or_inference_guarantee(site_tree, prefix):
|
||||
text = visible_text(contract(page(site_tree, prefix), "prerequisites"))
|
||||
require_concepts(text, r"1\.4\.15", r"prepared|ready|准备|配置",
|
||||
r"separate|isolated|独立|隔离", r"torch", r"audio|音频",
|
||||
r"model|weight|模型|权重", r"inference|transcription|推理|转写", NEGATION)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_immutable_sources_and_same_language_acceptance_links(site_tree, prefix):
|
||||
soup = page(site_tree, prefix)
|
||||
hrefs = [link["href"] for link in soup.select("article a[href]") if visible_text(link)]
|
||||
for path in ("README.md", "whisper/transcribe.py"):
|
||||
assert any(urlsplit(h).netloc == "github.com" and urlsplit(h).path ==
|
||||
f"/openai/whisper/blob/{WHISPER_REVISION}/{path}" for h in hrefs)
|
||||
for path in ("funasr/auto/auto_model.py", "funasr/utils/load_utils.py"):
|
||||
assert any(re.fullmatch(r"/modelscope/FunASR/blob/[0-9a-f]{40}/" + re.escape(path),
|
||||
urlsplit(h).path) and urlsplit(h).netloc == "github.com" for h in hrefs)
|
||||
for route in (f"/{prefix}docs/installation.html", f"/{prefix}docs/python-api.html",
|
||||
f"/{prefix}blog/meeting-transcript-acceptance.html"):
|
||||
links = [h for h in hrefs if not urlsplit(h).netloc and urlsplit(h).path == route]
|
||||
assert links, route
|
||||
if site_tree != SITE / "legacy":
|
||||
target = BeautifulSoup((site_tree / route.lstrip("/")).read_text(), "html.parser")
|
||||
for href in links:
|
||||
fragment = unquote(urlsplit(href).fragment)
|
||||
if fragment:
|
||||
assert target.find(id=fragment), (route, fragment)
|
||||
for rel in ("canonical",):
|
||||
assert soup.find("link", rel=rel)["href"] == f"https://www.funasr.com/{prefix}blog/{SLUG}"
|
||||
for lang, route_prefix in (("zh", ""), ("en", "en/")):
|
||||
rendered_lang = "zh-CN" if lang == "zh" and site_tree != SITE / "legacy" else lang
|
||||
alternates = soup.find_all("link", rel="alternate", hreflang=rendered_lang)
|
||||
assert len(alternates) == 1, (rendered_lang, alternates)
|
||||
assert alternates[0]["href"] == f"https://www.funasr.com/{route_prefix}blog/{SLUG}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_historical_numbers_are_not_promoted_as_new_capacity_or_coverage(site_tree, prefix):
|
||||
soup = page(site_tree, prefix)
|
||||
index = BeautifulSoup((site_tree / prefix / "blog/index.html").read_text(), "html.parser")
|
||||
card = index.select_one(f'a.post-card[href="/{prefix}blog/{SLUG}"]')
|
||||
for text in [*promotional_texts(soup), visible_text(card)]:
|
||||
assert not re.search(HISTORICAL_NUMBER, text, re.I), text
|
||||
article = copy.deepcopy(soup.select_one("article"))
|
||||
history = article.select_one('[data-long-audio-contract="evidence"]')
|
||||
if history and re.search(HISTORICAL_NUMBER, visible_text(history), re.I):
|
||||
text = visible_text(history)
|
||||
require_concepts(text, r"historical|archiv|历史", r"unverified|not.*verif|未.*核|未.*验证",
|
||||
r"coverage|tail|覆盖|尾", NEGATION)
|
||||
history.decompose()
|
||||
assert not re.search(HISTORICAL_NUMBER, article.get_text(" ", strip=True), re.I)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
def test_recipe_has_explicit_model_vad_device_and_matching_units(site_tree, prefix):
|
||||
tree = recipe_tree(page(site_tree, prefix))
|
||||
constructors = [node for node in ast.walk(tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name) and node.func.id == "AutoModel"]
|
||||
assert len(constructors) == 1
|
||||
kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in constructors[0].keywords}
|
||||
assert kwargs["model"] in ("iic/SenseVoiceSmall", "SenseVoiceSmall")
|
||||
assert kwargs["vad_model"] == "fsmn-vad"
|
||||
assert kwargs.get("device") == "cpu", "The demonstrated device must not depend on host GPU availability"
|
||||
assert kwargs["vad_kwargs"]["max_single_segment_time"] == 30000
|
||||
calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute) and node.func.attr == "generate"]
|
||||
assert len(calls) == 1
|
||||
args = {kw.arg: kw.value for kw in calls[0].keywords}
|
||||
assert ast.literal_eval(args["batch_size_s"]) == 300
|
||||
assert "input" in args
|
||||
|
||||
|
||||
def test_bilingual_python_recipe_has_identical_ast(site_tree):
|
||||
assert ast.dump(recipe_tree(page(site_tree, ""))) == ast.dump(recipe_tree(page(site_tree, "en/")))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ("", "en/"))
|
||||
@pytest.mark.parametrize("result", ([], [{"text": ""}], [{"text": "controlled transcript"}]))
|
||||
def test_actual_recipe_handles_empty_results_without_loading_models(site_tree, prefix, result, capsys, tmp_path, monkeypatch):
|
||||
tree = recipe_tree(page(site_tree, prefix))
|
||||
imports = [node for node in ast.walk(tree) if isinstance(node, (ast.Import, ast.ImportFrom))]
|
||||
model_imports = [node for node in imports if isinstance(node, ast.ImportFrom) and node.module == "funasr"]
|
||||
assert len(model_imports) == 1
|
||||
assert [(n.name, n.asname) for n in model_imports[0].names] == [("AutoModel", None)]
|
||||
for node in imports:
|
||||
if node is model_imports[0]:
|
||||
continue
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
assert node.module in ("pathlib", "importlib.metadata")
|
||||
else:
|
||||
assert all(n.name in ("pathlib", "importlib.metadata") for n in node.names)
|
||||
# Keep real Path/file checks and control flow; isolate only model and metadata boundaries.
|
||||
tree.body.remove(model_imports[0])
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "meeting.wav").write_bytes(b"")
|
||||
metadata_calls = []
|
||||
real_version = importlib.metadata.version
|
||||
|
||||
def controlled_version(name):
|
||||
metadata_calls.append(name)
|
||||
return "1.4.15" if name == "funasr" else real_version(name)
|
||||
|
||||
monkeypatch.setattr(importlib.metadata, "version", controlled_version)
|
||||
calls = []
|
||||
|
||||
class ControlledModel:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(("init", kwargs))
|
||||
|
||||
def generate(self, **kwargs):
|
||||
calls.append(("generate", kwargs))
|
||||
return copy.deepcopy(result)
|
||||
|
||||
code = compile(tree, SLUG, "exec")
|
||||
namespace = {"AutoModel": ControlledModel, "__name__": "__main__"}
|
||||
if not result or not result[0]["text"]:
|
||||
with pytest.raises((ValueError, RuntimeError, SystemExit)) as rejected:
|
||||
exec(code, namespace)
|
||||
assert str(rejected.value), "Empty results need an explicit, readable failure"
|
||||
else:
|
||||
exec(code, namespace)
|
||||
assert "funasr" in metadata_calls
|
||||
assert [name for name, _ in calls] == ["init", "generate"]
|
||||
audio = calls[-1][1]["input"]
|
||||
assert isinstance(audio, str) and not urlsplit(audio).scheme and Path(audio).is_file()
|
||||
output = capsys.readouterr().out
|
||||
if result and result[0]["text"]:
|
||||
assert result[0]["text"] in output, "Do not make empty handling pass by discarding successful output"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", (
|
||||
"Whisper caps at 30s; manually split the file.",
|
||||
"FunASR ingests audio of any length.",
|
||||
"GPU memory is independent of total file length.",
|
||||
"显存占用与文件总长无关。",
|
||||
"No benchmark was run. FunASR handles any length.",
|
||||
))
|
||||
def test_false_claim_detector_rejects_positive_promises(text):
|
||||
with pytest.raises(pytest.fail.Exception):
|
||||
reject_positive_false_claims(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", (
|
||||
"Whisper transcribe internally processes 30-second windows, not a 30-second file limit.",
|
||||
"It is not true that FunASR handles any length.",
|
||||
"Do not assume GPU memory is independent of total file length.",
|
||||
"不能保证显存占用与文件总长无关。",
|
||||
"Full-file CPU decoding remains; smaller ASR batches do not turn this into streaming.",
|
||||
))
|
||||
def test_false_claim_detector_accepts_explicit_corrections(text):
|
||||
reject_positive_false_claims(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern,text", (
|
||||
(MULTIPLICATION, "longest duration multiplied by the number of segments"),
|
||||
(CPU_PER_SEGMENT, "CPU handles segments individually"),
|
||||
(CPU_PER_SEGMENT, "CPU 按片段逐个处理"),
|
||||
))
|
||||
def test_equivalent_batching_grammar_is_accepted(pattern, text):
|
||||
require_concepts(text, pattern)
|
||||
Reference in New Issue
Block a user