作者构建桌面会议助手,将麦克风和系统音频接入实时转录、说话人识别及网页端摘要与语义检索。文章梳理转录停滞、视频声音误认、双音源冲突、人名识别错误和向量未存储等故障。
我不是在做又一个录音工具。
我在做一个会议助手。
一个放在桌面上的小应用,听着通话,把内容记录下来:
然后把对话保存到一个地方,方便团队以后找到。
录下一场三人通话。
你应该能看到清晰的转录文本、摘要,以及标在自己发言旁边的名字。
然后,我播放了一段 YouTube 视频。
应用却认定,YouTube 视频里的声音就是我。
Bug #1 —— 转录文本卡住了
Bug #2 —— YouTube 视频成了我
Bug #3 —— 两个麦克风,一个真相
Bug #4 —— 电脑音频让我成了陌生人
Bug #5 —— “记住这个声音”什么也没记住
Bug #6 —— “Okafor”变成了“Aquifer”
Bug #7 —— 从未存进去的向量
最终架构
Your Microphone + Your Computer's Audio
↓
Live Transcription
↓
Who Is Speaking?
↓
Save to the Web App
↓
Summary + Search
最终效果应该是:
每一行都标着正确的名字。
以后可以按语义找到内容,而不只是靠关键词。
听起来很简单。实际并不是。
这个系统由四部分组成。
桌面应用用 Electron 构建。它会采集麦克风音频,也可以把电脑声音作为第二个声道一起采集,这样通话另一端的声音就能与你的声音分开录制。
你说话时,音频会持续流向 Deepgram 的语音转文本模型。中间经过一个小型本地中继服务,由它保管 API key,因此密钥不会随应用一起分发。
返回的文字已经按不同声音分好了。Deepgram 把这叫作 diarization,也就是区分出 Speaker 0、Speaker 1、Speaker 2。
但 diarization 只知道这些声音来自不同的人。
识别具体是谁,是说话人识别引擎 VoiceBio 的工作。
你只需要读一遍简短的段落。它就会建立 voiceprint,也就是你声音的数学指纹。voiceprint 经过加密,只保存在你自己的电脑上。
录音过程中,每个声音都会取几秒钟,与它进行比对。引擎返回一个相似度分数:分数高,表示“这是你”;分数低,表示“这是别人”。
Web 应用永远接触不到你的声音。它只接收姓名标签。
数据库负责保存记录。向量数据库 Pinecone 负责建立索引。
每份转录文本都会被切成片段、转换为 embeddings 并存储起来,这样你以后就能按表达的意思搜索,而不只是按说过的字词搜索。
录音进行到一半,实时文字停住了。
不是变慢,而是彻底停住。
日志显示,有一次声音验证整整耗时 120 秒。
声音验证沿用了 voiceprint 注册时的超时设置。注册需要上传一段较长的录音并进行训练,所以给了两分钟。
验证一段五秒钟的音频,应该只需要一秒左右。
于是,我给验证操作单独设置了较短的超时时间。现在,验证太慢就会迅速失败,转录文本仍然能继续输出。
超时不是一个无关紧要的细节。它决定了用户要为什么等待。
我开始录音,播放一段视频,然后开口说话。
视频里的声音被标成了我。
我却被标成了“Speaker 2”。
等我的名字终于出现时,已经过去了大约 15 秒。
在任何声音通过验证之前,应用先做了一个猜测:“第一个听到的声音,大概就是用户。”
在安静的房间里,这个猜测还算合理。
但只要有别的声音先响起来,它立刻就错了。
而真正的验证需要先收集几秒钟的语音,再加上处理时间,所以这个猜测会在屏幕上停留很久,看起来就像已经确认的结果。
如果你已经有 voiceprint,那么在引擎确认之前,谁都不能被认作“你”。在此之前,姓名会以灰显状态显示,并标注“Identifying…”。
但 15 秒还是让人觉得太慢。
后来,我借鉴专门的会议记录工具的做法,想到了一个更好的办法。
别问这个声音听起来像谁。
先问它来自哪个麦克风。
Channel 0 → Your Microphone → You
Channel 1 → Your Computer → Everyone else on the call
Deepgram 在同一条连接上转录两个声道,并为每句话标上所属声道。你的话来自你的麦克风,因此你的名字可以立即显示。
如果用扬声器而不是耳机,麦克风也会听到通话另一端的声音。同一句话会出现两次:一次来自电脑,一次来自我的麦克风。
我加了一个回声过滤器:如果电脑声道在两秒内说过相同的话,就丢弃麦克风声道里的那一行。但简短回应绝不能丢,因为别人说话时插一句“yeah”,确实是人们会有的正常交流。
接着,又出现了一个更隐蔽的问题。
在说话的间隙,少量回声漏过了过滤器,别人的话上会短暂闪出我的名字。我试着先等一段“安静窗口”,再信任麦克风。先是两秒,然后是四秒。
无论窗口设多长,都无法保证可靠。
声道告诉你音频来自哪里,却不能告诉你是谁说的。
因此,只要存有 voiceprint,麦克风里的声音仍然要经过验证。
打开电脑音频采集后,应用完全认不出我了。
similarity: -11 → not you
以前,我的声音得分大约是 +23。其他人的得分大约是 -57。
-11 落在两者之间,一个很奇怪的位置。
引擎并没有判断错。
是我给它喂了垃圾数据。
双声道音频采用交错存储:一个采样来自麦克风,下一个来自电脑,如此交替。
但截取我声音片段的代码,仍然假定音频只有一个声道。
所以,它发出去的片段:
截取的时间位置不对。
而且把麦克风和电脑的声音逐个采样混在了一起。
修复方法是:按正确的采样速率,只从每个声音所属的声道截取音频。
Me +9.98 → recognized, locked
Computer audio -57 → correctly someone else
还有一个额外收获:验证我的声音时,不再连带把通话另一端的音频一起送过去。
应用有个功能:给说话人起个名字,比如“Milana”,再勾选“记住这个声音”,以后录音时就能自动认出她。
我给她起了名字,然后又录了一次。
没有任何人被标成 Milana。
第一个问题是,名字会立即应用到转录文本上,但训练她的 voiceprint 需要大约 20 秒的语音。语音不够时,训练会悄悄失败。无论成功与否,界面都显示成功。
第二个问题更有意思。
为了凑够 20 秒,我把她说的短句拼成一段音频,去掉了中间的空隙。结果,在 40 秒的音频里,引擎始终只能找到大约 6 秒可用的语音。
同样的音频,如果保留停顿,作为一段连续音频上传,就能立即完成注册。
句子之间的生硬剪切干扰了引擎的语音检测。
用她说话最密集的一段连续音频来训练。
训练失败,就明确告知用户。
随着她的语音不断增加,自动重试。
接下来,轮到文字本身的问题。
我没有继续猜,而是开始测量。我把一份内容已知的文稿通过实际生产环境使用的完整处理流程进行流式转录,再逐词对比。
词错误率为 9.8%。
大多数错误出在人名上:
Okafor → "Akhafar", "Aquifer"
Priya Raghunathan → "Priyuragunathan"
解决办法是告诉模型可能会出现哪些名字。Deepgram 把这叫作 keyterm prompting。
这份名单来自你自己的名字、已保存声音对应的姓名,以及一份可以自行编辑的简短列表。
词错误率降到了 3.3%。所有名字都正确了。
我还试过延长等待时间,再判定一句话结束。结果更差,错误率到了 12%,所以我没有改动这项设置。
需要坦诚说明:我是用合成语音测量的。真实房间里的笔记本麦克风,改善幅度会更小。但人名往往是大家最先核对的文字。
录音保存之后:
Save to Database (the record)
↓
Answer the app immediately
↓
In the background:
Chunk → Embed → Store in Vector Index
切分转录文本与切分文章不同。我按说话人轮次切分,每个片段大约 1,200 个字符,每行写成“姓名:发言”的形式,并把上一个片段的最后一行带入下一个片段,让问题和回答能保留在一起。
每个片段都会存储元数据:录音属于谁、属于哪个 workspace、包含哪些行,以及跳转到对应准确时刻的链接。
这里,隐私很重要。每个用户在索引中都有自己的独立空间,避免一个人的私人对话出现在另一个人的搜索结果里。而且,每条命中结果在展示前,都会再次与数据库核验。
上线了。测试了。搜索了。
后台任务的状态却显示失败:
You've reached the max namespaces allowed in this index (100).
索引满了。100 个空间。我们的是第 101 个。
受影响的还不只是这个功能。整个应用中新建的每个项目和 workspace,都会以同样的方式失败。那 100 个空间里,有 18 个对应的内容早就已经删除了。
排查过程中,我又发现了一个问题。
删除调用传入的是普通的 ID 列表,而库要求把这些 ID 包在一个对象里。所以,删除文档向量的操作一直在悄悄失败,什么也没删掉。已经删除的内容仍然能被搜到。
删除操作已经修好了。
namespace 数量上限涉及套餐和设计选择:升级、清理,或者共用一个空间并采用更严格的过滤条件。写下这些文字时,我们还在做决定。
不过,有一点让我很庆幸。
因为数据库保存的是正式记录,而向量只是索引,所以这次失败没有丢失任何内容。每份录音都已经保存好,只等索引腾出空间,就能建立索引。
Microphone ──┐
├──► Two-Channel Stream ──► Local Relay ──► Speech-to-Text
Computer ────┘ (names hinted)
│
▼
Lines, split by voice
│
┌──────────────────────────────────┤
▼ ▼
Voice check, own channel only Echo filter
vs voiceprint on this computer
│
▼
You / Named / "Identifying…"
│
▼
Upload (retry-safe, works offline)
│
▼
Database (the record) ──► Summary
│
▼
Chunk ──► Embed ──► Vector Index (per user)
│
▼
Search by meaning ──► check owner ──► open the exact line
灰显的“Identifying…”胜过一个看似确定、实际错误的名字。
声道信息获取成本低,也是很有力的线索。但它不是证明。
-11 不是声音引擎的错,而是我的音频切片出了问题。
一份内容已知的文稿,加上词错误率,就让我知道问题在人名,而不是模型。
当数据库是事实来源,向量只是索引时,向量服务故障只会造成不便,不会造成数据丢失。
比如 namespace 数量上限。还有批量限制:embedding 模型每次调用接受 96 个输入,而我们一次发送了 120 个。
“记住这个声音”和删除操作,都是什么也没做,却报告成功。
做这个项目之前,我以为弄清谁说了什么,是模型的问题。
选一个好的语音模型,再选一个好的声音引擎,就完事了。
音频格式问题。
声道布局问题。
向量数据库限制。
每修好一个问题,都会暴露出另一个隐藏的薄弱环节。
现在,这个应用可以实时转录通话,把你的名字正确标在你的发言上,将通话另一端的声音单独保留,并把所有内容保存到团队能找到的地方。
最大的收获,并不是选了一个更好的模型。
而是认识到:弄清谁说了什么,是整个处理流程的问题,不只是模型的问题。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。