口播静音切除:50ms 窗 RMS、扫描线判定与切不坏句尾的收边算法
停顿是口播的暗伤
一段十分钟的口播,字与字之间的气口、想词时的「嗯——」、句与句之间的呼吸,加起来常占两三成时长。播客老手管这叫「尿点」,听众的进度条不会等人。剪辑软件里手动一段段找静音、剪掉、拼接,十分钟录音要折腾半小时——而静音检测恰恰是规则明确、机器比人眼更稳的那类活:响度低于阈值、持续够久、不是句中的自然停顿,三个条件一筛,切完的成片节奏立刻紧凑。
🔧 口播静音切除 在浏览器里跑完整条链:解码、检测、预览、拼接、导出 WAV。这篇拆开它的算法内核。
第一步:把波形变成响度序列
检测的地基是 RMS(均方根)响度:每个 50ms 窗口内,把采样值平方、求和、除以窗长、开方,得到这一窗的「有效幅度」,再用 20·log10(rms) 换算成 dBFS——数字满幅为 0 的对数刻度,安静窗的值是负几十。为什么用 RMS 而不是峰值?一个孤立的爆点(鼠标磕到桌子的咔哒)会把峰值顶得很高,但一窗之内只占两三个采样,RMS 几乎无感——用峰值判静音,一颗尘埃就毁掉一整段判定;RMS 量的是窗口的整体能量,天然免疫瞬时毛刺。
为什么窗长定 50ms?这是一场双向妥协:窗太短(10ms),发音起落的包络被切碎,句中的自然停顿会被误判成可切静音,切出来的 speech 被剪得坑坑洼洼;窗太长(200ms),短停顿被平均进有声段而漏检,检出的静音边界也糊成一团、切不准位置。50ms 大致对应音节间隙的下限,是口播场景的甜点位。零 RMS(纯数字静音)取对数会得负无穷,实现对这类窗直接钳制到 -120 dBFS,一个低于一切实用阈值的下限,避免 NaN 污染后续比较。
dBFS 这本账的来龙去脉(为什么满幅是 0、和声压级 SPL 差在哪),在《WebAudio 响度刻度》里专门算过,这里只取一个结论:阈值是相对满幅的刻度,-40 dBFS 意味着这一窗的能量只有满幅信号的万分之一。
第二步:扫描线收集运行段
有了逐窗的「低于/不低于」布尔序列,静音段就是其中的连续真值运行段(run)。实现是一个教科书式的单遍扫描:维护 runStart 游标,窗变真且不在运行中则开段,变假则收段——收段时先核对时长是否达到最短静音门槛(默认 0.3s),不达标弃掉,这是「句中自然停顿不切」的保障,人说话的字间隙只有一两百毫秒,全靠这道门槛保命。循环末尾补一个哨兵窗口强制收最后一段——文件恰好在静音中结束时,扫描线到边界没有「变假」事件,不补哨兵就永远收不了尾。
| |
阈值本身的默认值是 -40 dBFS,配套三档经验:安静房间的口播用默认;街头、风扇、空调房提高到 -30,否则环境底噪整段都「不够静」、一刀全切(工具对峰值偏低的录音会提示先调到 -50 再试,防的正是整段误判);棚录极干净可下探 -50 只切纯停顿。浮点边界比较全部留了 1e-9 的容差——end - start >= minSilence - 1e-9 这类写法看着啰嗦,没有它,0.3s 整的停顿可能因为浮点误差判成 0.2999999 而漏切。
第三步:收边——算法里的人情世故
切纯静音不难,难的是不切掉「人的气息」。句尾的气声、词尾的轻音,响度早已低于阈值,但它们是有声内容的一部分——直接按检测边界剪,成片听感生硬,像说话人被掐住脖子。解法朴素而有效:每个静音段两侧向内各收 50ms padding,真正剪掉的是中段,边界附近留给呼吸。收边后段长归零(短于两个 padding 的静音)就整段放弃不切——两句话只隔一口气,宁可不切也不冒险。
收完边的静音段还要做一次防御性合并:相邻段间隔小于容差就并成一段,然后一次扫描生成互补的保留段列表。至此整条时间轴被切成静音与保留的交替序列,每段都以秒为单位带着精确起止。
第四步:拼接与导出
最后一步是把保留段的采样拼成新文件。实现绕开了 WebAudio 的 OfflineAudioContext——不重采样、不做任何 DSP,直接按保留段把各声道的 Float32Array 逐块 set 进预分配的输出数组:帧数先累加一遍再开空间,多声道对齐拷贝,声道间零错位。这是「无损」二字的真实含义:保留区间内的每一个采样原样进入成品,改变的只是区间之间的邻居关系。拼好的浮点采样按 16-bit PCM 编码进 WAV 容器导出,波形图上静音区标红、保留区原色,切之前先看一眼红区分布,试听对比再下载——机器给判定,人做终审。
顺带一提,这条检测链的产出不止于切除:🔧 音频裁剪 的手动区间、🔧 音频降噪 的底噪样本分析、🔧 音频波形图 的峰值抽取,共享着同一套「解码 → 逐窗统计 → 时间轴标注」的骨架,参数不同、各管一段。它们连同录音、混音工具,都收在音频创作工具箱专题里,全部本地计算,录音不出浏览器。