mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
docs: add bilingual meeting transcript acceptance guide (#3681)
Publish a reproducible structural acceptance guide with explicit quality limits and bilingual discovery. Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
@@ -24,9 +24,10 @@
|
||||
"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": "8cbf35e65aff908023ac299ddef94f157cf57235b271d79da59c59f7146d1500",
|
||||
"blog/index.html": "92af7f61f70b676eb942c889b4501cab5c328a0eb099bad487b9adb9b8d2ef16",
|
||||
"blog/japanese-speech-recognition.html": "399f5ce84e68ac00854bdf70b52b1cde6795efca7c800fd6492035a37a7c1b68",
|
||||
"blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281",
|
||||
"blog/meeting-transcript-acceptance.html": "3e35d1f283c7005977ad8cbd079e38c51e14b51f04f50cedb5636e2d4baf38bd",
|
||||
"blog/punctuation-restoration-python.html": "6cd26e03bf75b4343afd7b351c2681ebd513b058fb549c5888802be9ff3a1229",
|
||||
"blog/self-hosted-deepgram-assemblyai-alternative.html": "13b4287b5ef0d3159bf0af09ece3bc1fea66f13390b13c7f33b8b5e85351a1f5",
|
||||
"blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a",
|
||||
@@ -36,6 +37,7 @@
|
||||
"blog/speech-to-text-python-transcribe-audio.html": "87335710235dfdec9e01288f03a9a294c7bc3f730110e8d06f2b4c9d4906e284",
|
||||
"blog/speech-to-text-timestamps-python.html": "78364db117dfbce45b03739679764cec7ab1d0a860f321c67154f33392fc6b8d",
|
||||
"blog/subtitle-edit-fun-asr-sensevoice-local-subtitles.html": "f8d5870b6cf9dbdfd4e943fdccb7ae8f091472f1dcbd46bd7db90bd36e3e7846",
|
||||
"blog/transcript-audit.py": "1b77ee66d8e2ef734277a13689aa225598020c23193391a0a3eff598148f43c2",
|
||||
"blog/voice-activity-detection-python.html": "9055206633caecb83c11031f273c8c9297852f44dfe50984e03ab59a7deda6b5",
|
||||
"blog/which-funasr-model.html": "0109fe2d66f85c3cc917c822671ec0fb137247ee407f6c33459e6f2cadd05b5d",
|
||||
"css/742.48d54c7d.css": "38906de250e9a46352e178d2744831db647ee8dfa8cb6d70cbe68f40e2dfcb5b",
|
||||
@@ -64,9 +66,10 @@
|
||||
"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": "622a53a47a49834e1720fe1cad96ebb4768248324ef41ea528dc1c0c05145e41",
|
||||
"en/blog/index.html": "d5a5787e0cf9868752e4c94ad4e699f616f6d81b1b9bc9e38332f8690cd2a017",
|
||||
"en/blog/japanese-speech-recognition.html": "c476adcc2be1ed7c19e2345cc91b8ee6a0e04efd794b9b6476dd5d9b3a2c8b04",
|
||||
"en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7",
|
||||
"en/blog/meeting-transcript-acceptance.html": "3183682d2cae0ce18f68e3bd33e22b9effb4de699f6a4d821938cc03dd6835bf",
|
||||
"en/blog/punctuation-restoration-python.html": "d6e30049f48d9dc6e6bc22eb567830013e4161eca51215616738b675dd1ffc40",
|
||||
"en/blog/self-hosted-deepgram-assemblyai-alternative.html": "73b2fc101101fd2408ed49fdb2b0995ada59dec0016a91ed49efb856ebb8de53",
|
||||
"en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a",
|
||||
@@ -110,7 +113,7 @@
|
||||
"models.html": "01209193cd48363f00789061fbfad0a2b387582e2507ae5e875687773ef52fbc",
|
||||
"quickstart.html": "a543bf07c2cfc9a69ef3831f15c9e9d3e571a826a73cf7e4bdc8e9eb7b910254",
|
||||
"robots.txt": "1305b0f680b3e9f73b01301dc474423230dffab85731a379a694f30c73c07f2d",
|
||||
"sitemap.xml": "4ea239675f5966ec8bc1408300c6aca472e83974fedd78d48c021e2b21822b7e",
|
||||
"sitemap.xml": "1fc88be1d337d7fac6acf0dd61e6b3416f6808f5d6e8fc0006fb9b15cba6e039",
|
||||
"static/liveplayer/liveplayer-component.min.js": "ea10fda35574cc3a77a3e7187095467d1a409009a2d39690051d33d0e7055d30",
|
||||
"static/liveplayer/liveplayer-lib.min.js": "ad67b4e1188c586ec218674a6db9968daf71ed4fbc262258f061c1f931d54250",
|
||||
"static/offline/index.html": "868781c621ddabe5d3f088d6b739e0c08e6cdc150587af6515cc323852018e62",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HTTP 200 不等于转写成功:会议录音的验收清单 | FunASR</title>
|
||||
<meta name="description" content="从会议纪要、字幕和说话人剪辑出发,区分 ASR 文本、匿名标签、时间戳与尾部覆盖;附经过测试的 Python 结构检查器。">
|
||||
<meta property="og:title" content="HTTP 200 不等于转写成功:会议录音的验收清单"><meta property="og:description" content="从会议纪要、字幕和说话人剪辑出发,区分 ASR 文本、匿名标签、时间戳与尾部覆盖;附经过测试的 Python 结构检查器。"><meta property="og:type" content="article"><meta property="og:url" content="https://www.funasr.com/blog/meeting-transcript-acceptance.html"><meta property="og:image" content="https://www.funasr.com/img/funclip-v2-1-0-interface.jpg">
|
||||
<link rel="canonical" href="https://www.funasr.com/blog/meeting-transcript-acceptance.html"><link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/meeting-transcript-acceptance.html">
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"HTTP 200 不等于转写成功:会议录音的验收清单","datePublished":"2026-09-09","dateModified":"2026-09-09","author":{"@type":"Organization","name":"FunASR"},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/blog/meeting-transcript-acceptance.html"}}</script>
|
||||
<style>body{margin:0;font-family:system-ui,sans-serif;color:#202827;line-height:1.8;background:#fff}*{box-sizing:border-box}a{color:#126a59}a:hover{text-decoration:underline}.container{max-width:920px;margin:auto;padding:0 24px}.nav{border-bottom:1px solid #dce3df;padding:14px 0}.nav .container{display:flex;align-items:center;gap:20px}.nav-links{margin-left:auto;display:flex;gap:16px}.nav-logo{font-weight:700}.nav-btn{font-weight:600}article{padding:48px 0 64px}h1{font-size:2rem;line-height:1.3;letter-spacing:0}h2{font-size:1.4rem;margin-top:36px;letter-spacing:0}.lead{font-size:1.1rem}.meta,figcaption{color:#5e6864;font-size:.875rem}figure{margin:24px 0}.article-image{display:block;max-width:100%;height:auto}pre{overflow:auto;padding:18px;background:#f3f6f5;border:1px solid #dce3df;border-radius:6px;line-height:1.6}code{font-family:ui-monospace,monospace}p code,li code{overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}th,td{padding:10px;text-align:left;vertical-align:top;border-bottom:1px solid #dce3df}th{background:#f3f6f5}footer{border-top:1px solid #dce3df;padding:24px}@media(max-width:700px){h1{font-size:1.6rem}.nav-links{display:none}.container{padding:0 20px}table{font-size:.9rem}th,td{padding:7px}}</style>
|
||||
</head><body>
|
||||
<nav class="nav"><div class="container"><a class="nav-logo" href="/">FunASR</a><div class="nav-links"><a href="/deploy/">部署</a><a href="/blog/">技术博客</a></div><a href="/en/blog/meeting-transcript-acceptance.html">EN</a><a class="nav-btn" href="/go/github">GitHub</a></div></nav>
|
||||
<article><div class="container">
|
||||
<h1>HTTP 200 不等于转写成功:会议录音的验收清单</h1>
|
||||
<p class="meta">2026-09-09 · 应用工程 · 阅读约 8 分钟</p>
|
||||
<p class="lead">一次请求返回了文本,离“这份会议记录可以交给同事”还有多远?把 ASR 接进字幕、会议纪要或按说话人剪辑时,需要分别验收内容、说话人、时间和处理完整性。下面用 FunASR 的 MOSS 结果讲清这些层次,并提供一个只依赖 Python 标准库的结构检查脚本。</p>
|
||||
<figure><img class="article-image" src="/img/funclip-v2-1-0-interface.jpg" alt="FunClip 的视频、字幕与剪辑工作区"><figcaption>应用出口示意:已有 FunClip 工作区。不是下文合成样例的运行截图。</figcaption></figure>
|
||||
<h2>先分清四个问题</h2>
|
||||
<table><thead><tr><th>检查层次</th><th>要回答的问题</th><th>不能用什么代替</th></tr></thead><tbody>
|
||||
<tr><td>请求与结构</td><td>请求完成了吗?字段、单位、顺序是否符合约定?</td><td>HTTP 200、非空 JSON 不能证明内容正确。</td></tr>
|
||||
<tr><td>文本</td><td>名字、数字、否定词和关键决策是否转对?</td><td>语句流畅不能证明没有漏句或幻觉。</td></tr>
|
||||
<tr><td>说话人</td><td>每次轮次和重叠发言分给了谁?</td><td>输出了两个标签,不等于两位人物分得正确。</td></tr>
|
||||
<tr><td>时间与覆盖</td><td>字幕能定位到正确声音吗?开头、中段和结尾有无遗漏?</td><td>最后一个时间戳接近总时长,不等于全程转写完整。</td></tr>
|
||||
</tbody></table>
|
||||
<p>三项常见混淆值得单独拆开。<strong>VAD</strong> 检测哪些时间段可能有语音;<strong>ASR</strong> 给出说了什么;<strong>说话人分离(diarization)</strong> 给匿名说话人划分发言区间。它们不是同一个评测目标。<code>S01</code> 是当前录音内的标签,不是姓名、已注册声纹或跨录音身份。</p>
|
||||
<h2>给应用选对输出,而不只是选模型</h2>
|
||||
<p><a href="https://github.com/OpenMOSS/MOSS-Transcribe-Diarize">MOSS-Transcribe-Diarize</a> 是 OpenMOSS 维护的第三方模型。FunASR 集成把它的输出整理成文本和带匿名标签的时间段;采用这条路径不需要再外接 VAD 或说话人模型。不要为了“加上 VAD”先把会议任意切成独立请求,然后假定每段的 <code>S01</code> 都是同一个人。</p>
|
||||
<ul>
|
||||
<li><strong>整句字幕或说话人片段:</strong>段级时间戳是合适的接口,但仍须回听边界与说话人轮次。</li>
|
||||
<li><strong>逐字高亮或任意文本精确剪辑:</strong>段级时间戳不够。选择有对应字/词对齐输出的路径,例如检查 Paraformer 的时间戳能力与适用语言,不能把一句话的时长平均分配成“准确字时间”。</li>
|
||||
<li><strong>会议纪要:</strong>先保留可回溯的原文、匿名标签与时间段,再生成摘要;错误的人名、数字或决策不能靠摘要模型自动修复。</li>
|
||||
</ul>
|
||||
<p>具体服务版本和安装命令集中维护在 <a href="/deploy/moss-transcribe-diarize.html">MOSS 部署页</a>及 <a href="/docs/moss-transcribe-diarize.html">集成指南</a>。本文不另写一套会随版本漂移的安装流程。FunClip 的能力与限制见 <a href="/blog/funclip-v2-2-0-moss-speaker-clipping.html">说话人剪辑实践</a>;模型选型从 <a href="/models.html">模型页</a>进入。</p>
|
||||
<h2>一个能自动化、但不冒充质量评估的检查器</h2>
|
||||
<p><a href="/blog/transcript-audit.py" download>下载 transcript-audit.py</a>,也可以直接阅读源码。它只读文件,不访问网络,不修改转写结果。输入是 FunASR <code>AutoModel.generate(...)[0]</code> 的单个 MOSS 结果对象,或仓库离线示例写出的 <code>.moss-vllm.json</code>;不是原始 vLLM <code>diarized_json</code>、SRT,也不是 Qwen3 示例的 <code>segments</code> 格式。</p>
|
||||
<p>以下是人为构造的 12 秒录音结果,不是真实模型输出,也没有准确率含义:</p>
|
||||
<pre><code data-example="audit-input">{
|
||||
"text": "Let's start. Agreed.",
|
||||
"sentence_info": [
|
||||
{"start": 1000, "end": 3500, "spk": "S01", "text": "Let's start."},
|
||||
{"start": 6000, "end": 8000, "spk": "S02", "text": "Agreed."}
|
||||
]
|
||||
}</code></pre>
|
||||
<p>把示例 JSON 作为 <code>result.json</code>。检查器使用毫秒,12 秒应传 12000,不是 12:</p>
|
||||
<pre><code>python3 transcript-audit.py result.json --duration-ms 12000</code></pre>
|
||||
<p>输出的 <code>segment_count</code> 是 2,<code>last_end_ms</code> 是 8000,<code>tail_not_covered_ms</code> 是 4000。<code>quality_verified</code> 始终为 <code>false</code>:程序没有听过音频,更没有人工参考答案,因此不能给出质量通过证书。Python 3.12 下的合成样例、非法字段和命令行行为有回归测试;它们验证检查代码,不验证模型。检查器选择拒绝零长度段,这是本文应用验收的额外约束,不表示模型适配器承诺所有时间段都大于零。</p>
|
||||
<ul>
|
||||
<li>缺少 <code>sentence_info</code>、非数值/无穷时间、反向、零长度或超出录音范围的区间会报错并以非零状态退出。</li>
|
||||
<li>空文本、开始时间乱序和区间重叠会列出从 0 开始的下标,保留原始结果以便检查。重叠可以是真实抢话,不能直接删除;检查器按开始时间排序判断相交,列出与先开始区间相交的片段原始下标;边界相接不算重叠。</li>
|
||||
<li>空结果会显式显示 0 段和完整的未覆盖尾部。静音录音可能合理,会议录音则需要核验输入或服务结果。</li>
|
||||
<li>尾部差值只是“最后一个输出区间到录音终点”的时间,既不是漏识别时长,也不是语音召回率。中间即使漏掉一句话,这个值也可能是 0。</li>
|
||||
</ul>
|
||||
<h2>从结构检查走到人工验收</h2>
|
||||
<ol>
|
||||
<li><strong>固定输入和配置。</strong>保留原音频散列、时长、采样率/声道、模型 revision、服务版本、请求格式和生成上限。记录重采样、混音与裁剪;裁掉静音后必须保存偏移映射,否则时间戳无法回到原视频。</li>
|
||||
<li><strong>回听高风险位置。</strong>开头与结尾、长静音两侧、说话人交替、重叠发言、低音量以及关键数字。抽查能发现问题,但抽查通过不等于整段准确。</li>
|
||||
<li><strong>把文本与说话人分开评估。</strong>有人工参考转写时,可按统一的标点、大小写及分词规则计算 CER/WER;有人工说话人时间标注时,再使用说话人指标。不要把“输出了两个标签”作为 diarization 的通过条件。</li>
|
||||
<li><strong>冻结说话人评测口径。</strong><a href="https://github.com/pyannote/pyannote-metrics/blob/5b197b13ba39ac9baad37da66659da196090053c/src/pyannote/metrics/diarization.py#L51">pyannote.metrics</a> 提供说话人错误率等指标;边界宽容区间(collar)和是否计入重叠语音会影响结果。比较系统时必须使用同一标注与配置,指标数字不能脱离这些前提。</li>
|
||||
<li><strong>完整性异常先定位,再调参。</strong>检查响应是否因生成上限中止、结构是否完整、客户端是否漏读。增大 token 上限只可能缓解某些截断,不保证召回;不能用“接口正常返回”关闭用户的问题。</li>
|
||||
</ol>
|
||||
<h2>上线前留下什么</h2>
|
||||
<p>遵循授权范围和保留期限,只保存必要的数据,并限制对录音与说话人标注的访问。一份可复查的交付应包含原音频标识、固定配置、原始结果、结构报告和人工确认记录。会议纪要保留到原文的回链;字幕检查可读性、定位及隐私;剪辑回看实际片段,不能只看时间段长度。涉及已知人物身份时另行设计授权、登记和验证流程,不把匿名聚类标签改名后当作身份识别。</p>
|
||||
<p>这篇文章的检查器是进入验收流程的第一道结构检查,不是模型排行榜,也不会替代回听与标注。下一步可以沿 <a href="/deploy/">部署中心</a>跑通实际工作负载,再到 <a href="https://github.com/modelscope/FunASR">FunASR 仓库</a>查看实现或提交带最小复现的问题。</p>
|
||||
<h2>来源与复现入口</h2>
|
||||
<ul>
|
||||
<li><a href="https://github.com/OpenMOSS/MOSS-Transcribe-Diarize">OpenMOSS 模型项目</a>:模型归属与任务定义;<a href="https://huggingface.co/OpenMOSS-Team/MOSS-Transcribe-Diarize/blob/e8681d68e7042738ffca8ac8212bc8fcb1131ab8/README.md">固定 revision 的模型卡</a>解释匿名标签。</li>
|
||||
<li><a href="https://github.com/modelscope/FunASR/blob/fdf1d042342e59569144209abdfacf312771d9be/examples/industrial_data_pretraining/qwen3_asr/transcribe_vllm_offline.py">本文检查的离线结果文件契约</a>:固定仓库提交,MOSS 分支写出单个结果对象。</li>
|
||||
<li><a href="https://github.com/pyannote/pyannote-metrics/blob/5b197b13ba39ac9baad37da66659da196090053c/src/pyannote/metrics/diarization.py#L51">pyannote.metrics 官方实现</a>:说话人指标与评测配置。本文没有运行或报告 DER/CER/WER。</li>
|
||||
</ul>
|
||||
</div></article><footer><div class="container">FunASR · 应用实践与技术解读</div></footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural audit for one FunASR MOSS result, not a quality evaluator."""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def number(value, name):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be a finite number in milliseconds")
|
||||
try:
|
||||
finite = math.isfinite(value)
|
||||
except OverflowError:
|
||||
finite = False
|
||||
if not finite:
|
||||
raise ValueError(f"{name} must be finite")
|
||||
return value
|
||||
|
||||
|
||||
def audit(payload, duration_ms):
|
||||
duration = number(duration_ms, "duration_ms")
|
||||
if duration <= 0:
|
||||
raise ValueError("duration_ms must be positive")
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("sentence_info"), list):
|
||||
raise ValueError("expected one result object with sentence_info")
|
||||
segments = payload["sentence_info"]
|
||||
last_end, previous_start = 0, -1
|
||||
speakers, empty, unordered, overlaps = set(), [], [], []
|
||||
intervals = []
|
||||
for index, segment in enumerate(segments):
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError(f"segment {index} must be an object")
|
||||
start = number(segment.get("start"), f"segment {index} start")
|
||||
end = number(segment.get("end"), f"segment {index} end")
|
||||
if not 0 <= start < end <= duration:
|
||||
raise ValueError(f"segment {index} must satisfy 0 <= start < end <= duration_ms")
|
||||
speaker, text = segment.get("spk"), segment.get("text")
|
||||
if not isinstance(speaker, str) or not speaker.strip() or not isinstance(text, str):
|
||||
raise ValueError(f"segment {index} needs a nonempty spk label and string text")
|
||||
speakers.add(speaker)
|
||||
if not text.strip():
|
||||
empty.append(index)
|
||||
if start < previous_start:
|
||||
unordered.append(index)
|
||||
intervals.append((start, end, index))
|
||||
previous_start, last_end = start, max(last_end, end)
|
||||
# Sweep chronologically, but report indices from the unchanged input.
|
||||
frontier = 0
|
||||
for start, end, index in sorted(intervals, key=lambda item: (item[0], item[2])):
|
||||
if start < frontier:
|
||||
overlaps.append(index)
|
||||
frontier = max(frontier, end)
|
||||
return {
|
||||
"segment_count": len(segments),
|
||||
"speaker_labels": sorted(speakers),
|
||||
"last_end_ms": last_end,
|
||||
"tail_not_covered_ms": duration - last_end,
|
||||
"empty_text_indices": empty,
|
||||
"out_of_order_indices": unordered,
|
||||
"overlap_indices": sorted(overlaps),
|
||||
"quality_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("result", type=Path)
|
||||
parser.add_argument("--duration-ms", type=float, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
report = audit(json.loads(args.result.read_text(encoding="utf-8")), args.duration_ms)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.error(str(error))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist | FunASR</title>
|
||||
<meta name="description" content="A practical checklist for meeting transcripts, subtitles and speaker clips: words, anonymous labels, timestamps and tail coverage, with a tested Python structural checker.">
|
||||
<meta property="og:title" content="HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist"><meta property="og:description" content="A practical checklist for meeting transcripts, subtitles and speaker clips: words, anonymous labels, timestamps and tail coverage, with a tested Python structural checker."><meta property="og:type" content="article"><meta property="og:url" content="https://www.funasr.com/en/blog/meeting-transcript-acceptance.html"><meta property="og:image" content="https://www.funasr.com/img/funclip-v2-1-0-interface.jpg">
|
||||
<link rel="canonical" href="https://www.funasr.com/en/blog/meeting-transcript-acceptance.html"><link rel="alternate" hreflang="zh-CN" href="https://www.funasr.com/blog/meeting-transcript-acceptance.html">
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist","datePublished":"2026-09-09","dateModified":"2026-09-09","author":{"@type":"Organization","name":"FunASR"},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/en/blog/meeting-transcript-acceptance.html"}}</script>
|
||||
<style>body{margin:0;font-family:system-ui,sans-serif;color:#202827;line-height:1.8;background:#fff}*{box-sizing:border-box}a{color:#126a59}a:hover{text-decoration:underline}.container{max-width:920px;margin:auto;padding:0 24px}.nav{border-bottom:1px solid #dce3df;padding:14px 0}.nav .container{display:flex;align-items:center;gap:20px}.nav-links{margin-left:auto;display:flex;gap:16px}.nav-logo{font-weight:700}.nav-btn{font-weight:600}article{padding:48px 0 64px}h1{font-size:2rem;line-height:1.3;letter-spacing:0}h2{font-size:1.4rem;margin-top:36px;letter-spacing:0}.lead{font-size:1.1rem}.meta,figcaption{color:#5e6864;font-size:.875rem}figure{margin:24px 0}.article-image{display:block;max-width:100%;height:auto}pre{overflow:auto;padding:18px;background:#f3f6f5;border:1px solid #dce3df;border-radius:6px;line-height:1.6}code{font-family:ui-monospace,monospace}p code,li code{overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}th,td{padding:10px;text-align:left;vertical-align:top;border-bottom:1px solid #dce3df}th{background:#f3f6f5}footer{border-top:1px solid #dce3df;padding:24px}@media(max-width:700px){h1{font-size:1.6rem}.nav-links{display:none}.container{padding:0 20px}table{font-size:.9rem}th,td{padding:7px}}</style>
|
||||
</head><body>
|
||||
<nav class="nav"><div class="container"><a class="nav-logo" href="/en/">FunASR</a><div class="nav-links"><a href="/en/deploy/">Deployment</a><a href="/en/blog/">Blog</a></div><a href="/blog/meeting-transcript-acceptance.html">中文</a><a class="nav-btn" href="/go/github">GitHub</a></div></nav>
|
||||
<article><div class="container">
|
||||
<h1>HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist</h1>
|
||||
<p class="meta">2026-09-09 · Application engineering · 8 minute read</p>
|
||||
<p class="lead">A transcription request returned text. Is it ready for meeting notes, subtitles or speaker-based editing? Treat transport, words, speakers and timing as separate acceptance questions. This article uses a FunASR MOSS result and a small standard-library Python checker to show what can be automated, and what still needs listening.</p>
|
||||
<figure><img class="article-image" src="/img/funclip-v2-1-0-interface.jpg" alt="FunClip video, subtitle and editing workspace"><figcaption>An existing FunClip workspace illustrates the downstream application. This is not a screenshot of the synthetic example below.</figcaption></figure>
|
||||
<h2>Four questions, four kinds of evidence</h2>
|
||||
<table><thead><tr><th>Layer</th><th>Question</th><th>Not a substitute</th></tr></thead><tbody>
|
||||
<tr><td>Request and structure</td><td>Did the request complete with the expected fields, units and ordering?</td><td>HTTP 200 and nonempty JSON do not establish correctness.</td></tr>
|
||||
<tr><td>Words</td><td>Are names, numbers, negations and decisions transcribed correctly?</td><td>Fluent text can still omit or invent speech.</td></tr>
|
||||
<tr><td>Speakers</td><td>Are turns and overlapping speech assigned consistently?</td><td>Two output labels do not prove correct separation of two people.</td></tr>
|
||||
<tr><td>Timing and coverage</td><td>Do subtitles locate the right sound? Are the beginning, middle and end covered?</td><td>An end timestamp near the recording duration does not prove complete transcription.</td></tr>
|
||||
</tbody></table>
|
||||
<p><strong>VAD</strong> detects regions that may contain speech; <strong>ASR</strong> transcribes words; <strong>speaker diarization</strong> assigns anonymous speakers to speech regions. They answer different evaluation questions. A label such as <code>S01</code> is local to a recording, not a name, an enrolled voiceprint or a cross-recording identity.</p>
|
||||
<h2>Choose the output contract for the application</h2>
|
||||
<p><a href="https://github.com/OpenMOSS/MOSS-Transcribe-Diarize">MOSS-Transcribe-Diarize</a> is a third-party model maintained by OpenMOSS. FunASR normalizes its output into text and timed segments with anonymous speaker labels. This path does not require an additional external VAD or speaker model. Do not arbitrarily split a meeting into independent requests and then assume that every chunk's <code>S01</code> identifies the same person.</p>
|
||||
<ul>
|
||||
<li><strong>Sentence subtitles or speaker clips:</strong> segment timestamps are an appropriate interface, but boundaries and speaker turns still need listening checks.</li>
|
||||
<li><strong>Word highlighting or arbitrary text-based precision edits:</strong> segment timing is insufficient. Choose a path with the required character/word alignment, checking capabilities and supported languages, for example Paraformer. Dividing a sentence duration evenly does not create accurate word timing.</li>
|
||||
<li><strong>Meeting notes:</strong> retain traceable transcript text, anonymous labels and time ranges before summarization. A summarizer cannot be assumed to repair incorrect names, numbers or decisions.</li>
|
||||
</ul>
|
||||
<p>Version-specific installation belongs in the maintained <a href="/en/deploy/moss-transcribe-diarize.html">MOSS deployment page</a> and <a href="/en/docs/moss-transcribe-diarize.html">integration guide</a>, not a second recipe that drifts independently. See the <a href="/en/blog/funclip-v2-2-0-moss-speaker-clipping.html">speaker-clipping walkthrough</a> for FunClip boundaries and the <a href="/en/models.html">model catalogue</a> for selection.</p>
|
||||
<h2>A structural checker, not a quality evaluator</h2>
|
||||
<p><a href="/blog/transcript-audit.py" download>Download transcript-audit.py</a> or read its source. It reads one file, makes no network requests and does not modify results. The input is one MOSS result object from FunASR <code>AutoModel.generate(...)[0]</code>, also written as <code>.moss-vllm.json</code> by the repository's offline example. It is not raw vLLM <code>diarized_json</code>, SRT or the Qwen3 example's <code>segments</code> schema.</p>
|
||||
<p>This deliberately constructed result represents a hypothetical 12-second recording. It is not model output and carries no accuracy claim:</p>
|
||||
<pre><code data-example="audit-input">{
|
||||
"text": "Let's start. Agreed.",
|
||||
"sentence_info": [
|
||||
{"start": 1000, "end": 3500, "spk": "S01", "text": "Let's start."},
|
||||
{"start": 6000, "end": 8000, "spk": "S02", "text": "Agreed."}
|
||||
]
|
||||
}</code></pre>
|
||||
<p>Use the example JSON as <code>result.json</code>. Units are milliseconds: pass 12000 for 12 seconds, not 12.</p>
|
||||
<pre><code>python3 transcript-audit.py result.json --duration-ms 12000</code></pre>
|
||||
<p>The report has <code>segment_count=2</code>, <code>last_end_ms=8000</code> and <code>tail_not_covered_ms=4000</code>. It always returns <code>quality_verified=false</code>: it has neither heard the audio nor received human reference annotations. Synthetic cases, invalid fields and CLI behavior are regression-tested on Python 3.12. Those tests validate the checker, not the model. Rejecting zero-length segments is an additional acceptance policy here, not a guarantee that the model adapter always emits positive-length intervals.</p>
|
||||
<ul>
|
||||
<li>Missing <code>sentence_info</code>, nonnumeric or infinite timing, reversed or zero-length intervals and intervals outside the recording produce an error and nonzero exit status.</li>
|
||||
<li>Empty text, nonchronological starts and overlapping intervals are listed by zero-based index without rewriting the data. Overlap may be real simultaneous speech; do not delete it automatically. The checker sorts by start time, marking the original indices of segments that intersect an earlier-starting interval. Touching boundaries do not count as overlap.</li>
|
||||
<li>An empty result explicitly reports zero segments and the full uncovered tail. That may be reasonable for silence, but requires investigation for a meeting recording.</li>
|
||||
<li>The tail is only the distance from the last output interval to the recording end, not a duration of missed speech or a speech-recall metric. A missing sentence in the middle can coexist with a zero tail.</li>
|
||||
</ul>
|
||||
<h2>Move from structural checks to acceptance</h2>
|
||||
<ol>
|
||||
<li><strong>Freeze input and configuration.</strong> Retain the audio hash, duration, sample rate/channels, model revision, server version, response format and generation limit. Record resampling, channel mixing and trimming. Removing silence requires an offset map if timestamps must locate the original video.</li>
|
||||
<li><strong>Listen at high-risk positions.</strong> Check the start and end, both sides of long silence, speaker changes, interruptions, quiet speech and critical numbers. Sampling can reveal problems; a successful sample is not proof of whole-recording accuracy.</li>
|
||||
<li><strong>Evaluate text and speakers separately.</strong> With a human reference transcript, measure CER/WER under consistent punctuation, case and tokenization rules. With human speaker/time annotations, evaluate diarization. The number of distinct output labels is not a diarization acceptance test.</li>
|
||||
<li><strong>Freeze the diarization scoring policy.</strong> <a href="https://github.com/pyannote/pyannote-metrics/blob/5b197b13ba39ac9baad37da66659da196090053c/src/pyannote/metrics/diarization.py#L51">pyannote.metrics</a> provides speaker metrics. Boundary collars and whether overlapping speech is scored affect results. Compare systems on the same reference and configuration, not detached numbers.</li>
|
||||
<li><strong>Diagnose incomplete output before tuning.</strong> Inspect termination due to generation limits, structural completeness and whether the client consumed the whole response. Raising a token limit may address some truncations, but cannot guarantee recall. A successful request is not grounds to close an unresolved user issue.</li>
|
||||
</ol>
|
||||
<h2>What should travel with the result?</h2>
|
||||
<p>Follow consent and retention requirements, store only necessary data, and restrict access to audio and speaker annotations. Retain the source-audio identifier, fixed configuration, original output, structural report and human acceptance notes. Link summaries back to transcript passages. Check subtitle readability, positioning and privacy. Replay edited clips instead of only checking interval lengths. Known-person identification needs a separate consent, enrollment and verification design; renaming anonymous cluster labels does not provide it.</p>
|
||||
<p>This checker is an entry point into acceptance, not a leaderboard or a substitute for listening and annotation. Start a real workload through the <a href="/en/deploy/">deployment centre</a>, then use the <a href="https://github.com/modelscope/FunASR">FunASR repository</a> for implementation details or a minimal reproducible issue.</p>
|
||||
<h2>Sources and reproduction</h2>
|
||||
<ul>
|
||||
<li><a href="https://github.com/OpenMOSS/MOSS-Transcribe-Diarize">OpenMOSS project</a>: ownership and task definition; the <a href="https://huggingface.co/OpenMOSS-Team/MOSS-Transcribe-Diarize/blob/e8681d68e7042738ffca8ac8212bc8fcb1131ab8/README.md">fixed-revision model card</a> explains anonymous labels.</li>
|
||||
<li><a href="https://github.com/modelscope/FunASR/blob/fdf1d042342e59569144209abdfacf312771d9be/examples/industrial_data_pretraining/qwen3_asr/transcribe_vllm_offline.py">Offline result-file contract inspected here</a>: fixed repository commit; the MOSS branch writes one result object.</li>
|
||||
<li><a href="https://github.com/pyannote/pyannote-metrics/blob/5b197b13ba39ac9baad37da66659da196090053c/src/pyannote/metrics/diarization.py#L51">Official pyannote.metrics implementation</a>: diarization metrics and configuration. This article does not run or report DER, CER or WER.</li>
|
||||
</ul>
|
||||
</div></article><footer><div class="container">FunASR · Applications and technical explainers</div></footer>
|
||||
</body></html>
|
||||
@@ -74,4 +74,6 @@
|
||||
<url><loc>https://www.funasr.com/en/blog/punctuation-restoration-python.html</loc><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://www.funasr.com/blog/speech-to-text-timestamps-python.html</loc><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://www.funasr.com/en/blog/speech-to-text-timestamps-python.html</loc><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://www.funasr.com/blog/meeting-transcript-acceptance.html</loc><lastmod>2026-09-09</lastmod></url>
|
||||
<url><loc>https://www.funasr.com/en/blog/meeting-transcript-acceptance.html</loc><lastmod>2026-09-09</lastmod></url>
|
||||
</urlset>
|
||||
|
||||
@@ -436,7 +436,16 @@ for (const viewport of [
|
||||
page.locator(`.launch-feature a[href="${release.article}"]`),
|
||||
).toBeVisible();
|
||||
const history = page.locator('.previous-release .post-card');
|
||||
await expect(history).toHaveCount(4);
|
||||
const historySlugs = [
|
||||
'meeting-transcript-acceptance.html',
|
||||
'funasr-v1-4-14-portable-source-release.html',
|
||||
'funasr-v1-4-5-pypi-llama-cpp-release.html',
|
||||
'funasr-v1-4-3-pypi-release.html',
|
||||
'funasr-v1-4-0-pypi-release.html',
|
||||
];
|
||||
await expect(history).toHaveCount(historySlugs.length);
|
||||
expect(await history.evaluateAll(cards => cards.map(card => card.getAttribute('href'))))
|
||||
.toEqual(historySlugs.map(slug => `${release.index}${slug}`));
|
||||
await expect(
|
||||
page.locator(`.previous-release a[href="${release.previous}"]`),
|
||||
).toBeVisible();
|
||||
@@ -445,7 +454,7 @@ for (const viewport of [
|
||||
rows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size,
|
||||
}));
|
||||
expect(indexLayout.overflow).toBeLessThanOrEqual(1);
|
||||
expect(indexLayout.rows).toBe(viewport.name === 'mobile' ? 4 : 1);
|
||||
expect(indexLayout.rows).toBe(Math.ceil(historySlugs.length / (viewport.name === 'mobile' ? 1 : 4)));
|
||||
|
||||
await page.goto(release.article);
|
||||
await expect(page.locator('h1')).toContainText('FunClip v2.2.0');
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Exercise the downloadable article example, not an untested prose copy."""
|
||||
import json
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
SITE = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = SITE / "legacy/blog/transcript-audit.py"
|
||||
SLUG = "meeting-transcript-acceptance.html"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audit():
|
||||
return runpy.run_path(str(SCRIPT))["audit"]
|
||||
|
||||
|
||||
def segment(start=0, end=1000, spk="S01", text="hello"):
|
||||
return dict(start=start, end=end, spk=spk, text=text)
|
||||
|
||||
|
||||
def test_reports_tail_without_claiming_missing_speech_or_quality(audit):
|
||||
report = audit({"sentence_info": [segment(end=469200)]}, 480240)
|
||||
assert report["tail_not_covered_ms"] == 11040
|
||||
assert report["quality_verified"] is False
|
||||
assert report["speaker_labels"] == ["S01"]
|
||||
|
||||
|
||||
def test_overlap_and_cross_recording_labels_are_not_identity_proof(audit):
|
||||
data = [segment(0, 2000), segment(1000, 3000, "S02"), segment(4000, 5000)]
|
||||
result = audit({"sentence_info": data}, 5000)
|
||||
assert result["tail_not_covered_ms"] == 0
|
||||
assert result["overlap_indices"] == [1]
|
||||
assert result["quality_verified"] is False
|
||||
|
||||
|
||||
def test_empty_and_unordered_segments_are_visible(audit):
|
||||
assert audit({"sentence_info": []}, 1000)["segment_count"] == 0
|
||||
result = audit({"sentence_info": [segment(500, 800), segment(0, 200, text=" ")]}, 1000)
|
||||
assert result["out_of_order_indices"] == [1]
|
||||
assert result["empty_text_indices"] == [1]
|
||||
assert result["last_end_ms"] == 800
|
||||
assert result["overlap_indices"] == []
|
||||
|
||||
|
||||
def test_unordered_overlaps_preserve_original_indices(audit):
|
||||
data = [segment(1000, 3000), segment(0, 2000, "S02")]
|
||||
assert audit({"sentence_info": data}, 4000)["overlap_indices"] == [0]
|
||||
|
||||
|
||||
def test_touching_boundaries_are_not_overlap(audit):
|
||||
data = [segment(1000, 2000), segment(0, 1000)]
|
||||
assert audit({"sentence_info": data}, 2000)["overlap_indices"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, [], {}, {"sentence_info": None},
|
||||
{"sentence_info": [None]}, {"sentence_info": [{}]}])
|
||||
def test_rejects_missing_schema(audit, bad):
|
||||
with pytest.raises(ValueError):
|
||||
audit(bad, 1000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration", [True, 0, -1, "1000", float("nan"), float("inf"), 10**400])
|
||||
def test_rejects_invalid_duration(audit, duration):
|
||||
with pytest.raises(ValueError):
|
||||
audit({"sentence_info": []}, duration)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"start": -1}, {"end": 0}, {"end": 1001}, {"start": True},
|
||||
{"end": float("inf")}, {"end": float("nan")}, {"start": "0"},
|
||||
{"spk": ""}, {"spk": 1}, {"text": None},
|
||||
])
|
||||
def test_rejects_bad_segments(audit, changes):
|
||||
with pytest.raises(ValueError):
|
||||
audit({"sentence_info": [segment(**changes)]}, 1000)
|
||||
|
||||
|
||||
def test_cli_success_and_failure_do_not_modify_source(tmp_path):
|
||||
path = tmp_path / "result.json"
|
||||
original = json.dumps({"sentence_info": [segment()]}).encode()
|
||||
path.write_bytes(original)
|
||||
command = [sys.executable, str(SCRIPT), str(path), "--duration-ms", "1200"]
|
||||
good = subprocess.run(command, capture_output=True, text=True)
|
||||
assert good.returncode == 0, good.stderr
|
||||
assert json.loads(good.stdout)["tail_not_covered_ms"] == 200
|
||||
bad = subprocess.run(command[:-1] + ["nan"], capture_output=True, text=True)
|
||||
assert bad.returncode != 0
|
||||
assert not bad.stdout
|
||||
assert path.read_bytes() == original
|
||||
|
||||
|
||||
def test_articles_index_sources_example_and_asset_are_consistent(audit):
|
||||
snippets = []
|
||||
for language in ["zh", "en"]:
|
||||
prefix = "" if language == "zh" else "en/"
|
||||
page = SITE / "legacy" / prefix / "blog" / SLUG
|
||||
soup = BeautifulSoup(page.read_text(), "html.parser")
|
||||
metadata = json.loads(soup.select_one('script[type="application/ld+json"]').string)
|
||||
assert metadata["datePublished"] == "2026-09-09"
|
||||
assert metadata["dateModified"] == "2026-09-09"
|
||||
assert soup.select_one("h1")
|
||||
assert soup.select_one('a[href="/blog/transcript-audit.py"]')
|
||||
assert soup.select_one('a[href*="OpenMOSS/MOSS-Transcribe-Diarize"]')
|
||||
assert soup.select_one('a[href*="pyannote/pyannote-metrics"]')
|
||||
assert soup.select_one('a[href*="/deploy/moss-transcribe-diarize.html"]')
|
||||
example = json.loads(soup.select_one('[data-example="audit-input"]').get_text())
|
||||
result = audit(example, 12000)
|
||||
assert result["tail_not_covered_ms"] == 4000
|
||||
assert result["quality_verified"] is False
|
||||
snippets.append(example)
|
||||
index = BeautifulSoup((SITE / "legacy" / prefix / "blog/index.html").read_text(), "html.parser")
|
||||
assert index.select_one(f'a[href="/{prefix}blog/{SLUG}"]')
|
||||
assert snippets[0] == snippets[1]
|
||||
Reference in New Issue
Block a user