Whistle 30 秒上限:长音频切分完整方案

Whistle 单次最多 30 秒。直接截断会丢字,正确做法是在安静处切——本文给出切点算法、可运行脚本与词时间戳回移方法。

官方没说清楚的部分:官方只说明「超过 30 秒需要先切分」,未给出切点选择方法。在词中间硬切会丢字,这是评论区反复出现的问题。

切段计算器

输入录音总时长,看看会切成几段(按 24 秒标记推算,实际切点会落在窗口内最安静的帧上)。

秒

为什么是 30 秒

Whistle 的编码器是非因果的:一个 3 秒处的词可以参考 12 秒处的信息。这意味着它必须一次看到整段音频,没法边听边出字。音频前端按 10 ms 步长生成帧,卷积 stem 三次减半后,30 秒音频压成 375 帧(每帧 80 ms)——这就是 30 秒上限的来源。长音频不是模型偷懒,是架构决定的。

三种处理方式

① 自己切成 ≤30 秒的片段再逐段转录(本文方案,最可控);② 用上层框架的流式接口,由框架内部切片与拼接;③ 换用支持长音频的模型。若你要的是会议转写,Whistle 本身就不合适——它在 AMI 会议集上词错率 26.07%,约每四个词错一个,换模型比优化切分更划算。

切点算法:在安静处切,不要在词中间切

关键参数:在每 24 秒标记附近搜索 ±3 秒的窗口,取窗口内能量最低的 20 ms 帧作为切点。为什么是 24 秒而不是 30 秒:留出余量,避免片段尾部被截断。为什么找安静帧:人说话的词与词之间有自然停顿,在停顿处切不会把任何一个词劈成两半。

import wave, numpy as np

def frame_rms(x, sr, frame_ms=20, hop_ms=10):
    f, h = int(sr*frame_ms/1000), int(sr*hop_ms/1000)
    win = np.lib.stride_tricks.sliding_window_view(x, f)[::h]
    return np.sqrt((win.astype(np.float64)**2).mean(axis=1)), np.arange(len(win))*h

def find_cut_points(x, sr, mark_s=24.0, search_s=3.0, max_s=30.0):
    maxlen = int(sr*max_s)
    if len(x) <= maxlen:
        return [0, len(x)]
    mark, window = int(sr*mark_s), int(sr*search_s)
    cuts, pos = [0], 0
    while len(x) - pos > maxlen:
        center = pos + mark
        lo = max(pos+1, center-window)
        hi = min(center+window, pos+maxlen-1)
        rms, offs = frame_rms(x[lo-int(sr*0.02):hi], sr)
        pos = lo + int(offs[int(np.argmin(rms))]) if len(rms) else pos+maxlen
        cuts.append(pos)
    cuts.append(len(x))
    return cuts

完整脚本:切分 + 转录 + 时间戳回移

切完之后每段单独送进 Whistle,再把各段的词时间戳加上该段在原录音里的偏移量,拼回完整时间轴。

import needle
from core.splitter import read_wav_mono, find_cut_points, shift_words

x, sr = read_wav_mono("long.wav")     # 必须是 16 kHz mono 16-bit PCM
cuts = find_cut_points(x, sr)
segments, language = [], None

for i in range(len(cuts)-1):
    piece = x[cuts[i]:cuts[i+1]]
    out = needle.transcribe(piece, language=language, word_timestamps=True)
    language = language or out["language"]   # 首片检测到的语言,后续锁定
    segments.append({"offset_s": cuts[i]/sr, "words": out.get("words", [])})
    print(out["text"])

words = shift_words(segments)          # 回移到整段录音的时间轴

四个坑

① 静音短路:引擎在解码前先测响度,低于阈值直接返回空文本,不会进入解码。整段静音的片段会得到空结果,这是设计如此,不是 bug。② 空片段:长录音里的静音区会被切成独立片段,产出空文本,拼接时要过滤。③ token 上限:单片段转写上限 320 个 token,语速极快的段落可能被截断。④ 单次请求上限约 1 小时,更长的录音需自行分批。

静音反而会输出 Thank you 是怎么回事

有用户在 Hacker News 上报告:把 Whistle 跑在一集电视剧上,它有时卡住、连续 60 秒输出「Thank you.」。官方的静音短路能处理纯静音,但超出 30 秒窗口的嘈杂长音频仍可能触发填充词。这是 16.9 MB 小模型的已知边界,遇到时优先怀疑音频是否超出了设计场景,而不是调参。

常见问题

能不能直接传 MP3?

Whistle 自己只能读 WAV(各种位深)与裸 PCM16。MP3、M4A、OGG、FLAC 等需要先转:ffmpeg -i in.mp3 -ar 16000 -ac 1 out.wav

切分会让首尾丢字吗?

在安静帧处切不会。但 30 秒边界附近如果有连续语音,找不到安静点,算法会退化为硬切——此时仍可能丢字。实测建议先跑一遍看切点的帧能量:接近 0 说明切在了静音上。

为什么不用 Whisper 的长音频方案?

Whisper 内部也是 30 秒窗口,只是它把补齐与滑动窗口封装好了。Whistle 把这些暴露给了调用方,代价是要自己实现,好处是你可以控制切点。

用你自己的音频测 —— 算出 Whistle 在你的音频上的真实词错率,30 秒出结果。

相关页面

数据口径:Whisper 与 Moonshine 的数值为各自作者公布值,Whistle 为 Cactus 自测(86,174 条,Whisper 归一化器),非同条件复跑。
核对日期:2026-10-09。