跳到主要内容

AI增强实时字幕与降噪

面向已有 WebRTC 通信链路的工程师,讨论如何在端侧或云端引入 AI 模型,做实时降噪与实时字幕(ASR),并把字幕通过 DataChannel 或 WebVTT 广播到接收端。覆盖 RNNoise / DeepFilterNet WASM 集成、AudioWorklet 与 SharedArrayBuffer 的数据流、Whisper Streaming 的端侧与云端取舍、VAD 触发与 partial transcript 修正、字幕渲染与多语言翻译流水线、性能预算与失败模式。

1. 端到端架构总览

1.1 数据流

1.2 关键约束

  • WebRTC 采集默认 48 kHz、单声道、Float32;Whisper 系列固定 16 kHz、Mono、Float32,需要重采样与去直流。
  • 降噪要在发送侧的音频图里完成,让对端听到的是已经降噪过的流,而不是只在本地"看到"降噪后的频谱。
  • 字幕是元数据,不是音频,必须走独立通道(DataChannel 或 SFU 元数据扩展),不要尝试把字幕塞进 Opus 注释。
  • AudioWorklet 是硬实时上下文,单次 process() 调用预算约 2.7 ms(128 帧 @48 kHz),超时会引发 glitch;任何阻塞、GC、console.log 都要谨慎。

1.3 何时不适用

  • 通话端是低端 Android(4×A53、2 GB RAM):端侧 Whisper-tiny 也可能超出预算,应该退化为云端 ASR。
  • 服务端是纯 SFU 不解码:云端字幕方案需要再额外接入 SFU 旁路转码,整体复杂度高于直接端侧出字幕。
  • 端到端加密(Insertable Streams / E2EE)开启时:服务器拿不到 PCM,云端方案必须改为发送端就近上传到 ASR 服务,而不是 SFU 旁路。

2. AudioWorklet 与 WASM 集成

2.1 为什么必须用 AudioWorklet

  • ScriptProcessorNode 跑在主线程,会被 React 渲染、postMessage 队列、GC 阻塞,已经被 Web Audio API spec 标记为废弃。
  • AudioWorklet 跑在独立的渲染线程,与主线程通过 MessagePort + SharedArrayBuffer 通信,可以做到稳定的 2.7 ms 周期。
  • WASM 模块如果在主线程加载、在主线程执行,等于把降噪 / ASR 抢回到主线程,所有 AudioWorklet 的实时性优势就废了。

2.2 SharedArrayBuffer 启用前置条件

部署侧响应头必须满足 cross-origin isolation,否则 SharedArrayBuffer 在浏览器里直接是 undefined

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

CDN、第三方脚本、内嵌的 iframe 都需要相应配合,常见反模式是只在主文档加了头但 SFU 的信令域名没加,导致 worker 加载就失败。

2.3 Lock-free RingBuffer

降噪 WASM(RNNoise 480 samples、DFN 480 或 960 samples)和 AudioWorklet(128 samples)的 frame 大小不同,必须用 ring buffer 桥接。SAB 上做单写单读 lock-free ring buffer,借鉴 LMAX Disruptor 的内存序:

// shared-ringbuffer.ts
// 单生产者单消费者 ring buffer:AudioWorklet 写、WASM worker 读,或反过来。
export class SharedRingBuffer {
static readonly HEADER_LEN = 2; // [writeIndex, readIndex]

private readonly header: Int32Array;
private readonly data: Float32Array;
private readonly capacity: number;

constructor(sab: SharedArrayBuffer, capacityFrames: number) {
this.header = new Int32Array(sab, 0, SharedRingBuffer.HEADER_LEN);
this.data = new Float32Array(sab, SharedRingBuffer.HEADER_LEN * 4, capacityFrames);
this.capacity = capacityFrames;
}

static create(capacityFrames: number): SharedArrayBuffer {
const bytes = SharedRingBuffer.HEADER_LEN * 4 + capacityFrames * 4;
return new SharedArrayBuffer(bytes);
}

/** 生产者写入;返回实际写入帧数(环满则丢弃尾部)。 */
push(chunk: Float32Array): number {
const w = Atomics.load(this.header, 0);
const r = Atomics.load(this.header, 1);
const free = this.capacity - ((w - r + this.capacity) % this.capacity) - 1;
const n = Math.min(chunk.length, free);
for (let i = 0; i < n; i++) {
this.data[(w + i) % this.capacity] = chunk[i];
}
Atomics.store(this.header, 0, (w + n) % this.capacity);
Atomics.notify(this.header, 0);
return n;
}

/** 消费者读取固定大小的 frame;不足则返回 0。 */
pull(out: Float32Array): number {
const w = Atomics.load(this.header, 0);
const r = Atomics.load(this.header, 1);
const avail = (w - r + this.capacity) % this.capacity;
if (avail < out.length) return 0;
for (let i = 0; i < out.length; i++) {
out[i] = this.data[(r + i) % this.capacity];
}
Atomics.store(this.header, 1, (r + out.length) % this.capacity);
return out.length;
}
}

为什么 capacity - 1:经典 ring buffer 留一个空位区分"空"和"满",否则 w == r 同时表示两种状态,读取端会读到旧数据。

2.4 AudioWorkletProcessor 与回写

// denoise-processor.ts,需要通过 audioWorklet.addModule 注册
declare const sampleRate: number;
declare const currentTime: number;

class DenoiseProcessor extends AudioWorkletProcessor {
private inRb!: SharedRingBuffer;
private outRb!: SharedRingBuffer;
private readonly tmp = new Float32Array(128);

constructor(options: AudioWorkletNodeOptions) {
super();
const { inSab, outSab, capacity } = options.processorOptions as {
inSab: SharedArrayBuffer;
outSab: SharedArrayBuffer;
capacity: number;
};
this.inRb = new SharedRingBuffer(inSab, capacity);
this.outRb = new SharedRingBuffer(outSab, capacity);
}

process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const input = inputs[0]?.[0];
const output = outputs[0]?.[0];
if (!input || !output) return true;

// 1. 写入麦克风采样(48kHz)
this.inRb.push(input);

// 2. 拉出 WASM worker 已经处理好的降噪后采样
const got = this.outRb.pull(this.tmp);
if (got === this.tmp.length) {
output.set(this.tmp);
} else {
output.fill(0); // underflow:宁可静音也别复读旧帧
}
return true;
}
}

registerProcessor('denoise-processor', DenoiseProcessor);

降噪算法本体在 worker 里跑:worker 不断 pull 48 kHz 输入、累积到 480 帧(RNNoise)后调用 WASM、把降噪后的 480 帧 push 回 outRb。

2.5 主线程装配

async function setupDenoisePipeline(stream: MediaStream): Promise<MediaStream> {
const ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
await ctx.audioWorklet.addModule('/denoise-processor.js');

const capacity = 48000; // 1s 缓冲
const inSab = SharedRingBuffer.create(capacity);
const outSab = SharedRingBuffer.create(capacity);

const src = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, 'denoise-processor', {
processorOptions: { inSab, outSab, capacity },
channelCount: 1,
channelCountMode: 'explicit',
});
const dst = ctx.createMediaStreamDestination();

src.connect(node).connect(dst);

// worker 拿到同样的 SAB
const worker = new Worker('/denoise-worker.js', { type: 'module' });
worker.postMessage({ type: 'init', inSab, outSab, capacity });

return dst.stream; // 这条流挂到 RTCPeerConnection.addTrack
}

反模式:把 MediaStreamSource 直接连到 MediaStreamDestination,让 worker 只做"旁听"。这样对端听到的还是原始未降噪的音频。

3. RNNoise 与 DeepFilterNet 取舍

3.1 一句话定位

  • RNNoise:2018 年 Xiph 出品,10 万参数级别的小 GRU,48 kHz 输入下专攻稳态噪声、键盘声、风扇。
  • DeepFilterNet(DFN2/DFN3):2022—2023 年提出的 two-stage 深度滤波网络,48 kHz,结构复杂得多,覆盖非稳态噪声、回响、低频噪声。

3.2 对比表

维度RNNoiseDeepFilterNet 2/3
模型大小约 85 KB(WASM 总体约 200 KB)DFN2 约 2.3 MB,DFN3 约 1.4 MB
采样率48 kHz 原生48 kHz 原生
帧长10 ms(480 samples)20 ms(960 samples),算法时延更大
单核 CPU 占用(桌面 i5)0.5%—1%DFN2 约 4%—6%,DFN3 约 2%—3%
移动端(Snapdragon 7 Gen 1)1%—2%DFN3 约 6%—10%
降稳态噪声优秀优秀
降非稳态(敲键盘、关门、婴儿哭)一般明显更好
抑制回响不处理DFN3 内置 dereverb 模型
语音损伤(PESQ/STOI)中等更低
许可证BSD-3-ClauseDFN 自身 Apache-2.0,预训练模型 Apache-2.0

3.3 选型建议

  • 桌面端会议、对延迟和 CPU 敏感:RNNoise 起步,足够把空调、风扇、键盘声压下去。
  • 移动端户外、嘈杂咖啡馆、多人后台噪声:DeepFilterNet 3 性价比更高,但要给中低端机做降级(关掉或回退到 RNNoise)。
  • 已经接了云端会议平台(Daily、Agora)自带降噪:不要叠加两层,第二层往往把语音也磨掉。

3.4 失败时的样子

  • RNNoise 在母语外的语种(强爆破音、强咝音)上偶尔会把辅音判为噪声,听起来"吞字"。
  • DeepFilterNet 在极高 SNR 场景(基本无噪声)下会过度抑制低频,男声变薄;可以把 attenuation lim 调到 30 dB 以下缓解。
  • WASM 模型未做 SIMD 编译时性能塌方:必须用 -msimd128 编译,并在 worker 里 feature detect。

4. Whisper Streaming:端侧 vs 云端

4.1 关键事实

Whisper 本体不是流式模型,它的设计输入是 30 秒窗口;所谓"streaming"是工程层面用 VAD 分段 + 滑动窗口 + 增量解码模拟出来的。任何方案都绕不开这一点。

4.2 端侧:whisper.cpp WASM / WebGPU

  • 模型:tiny(39M)、base(74M)、small(244M)。tiny 多语种约 75 MB,量化到 q5_1 约 40 MB;small 量化后约 250 MB。
  • 推理后端:纯 WASM SIMD 在桌面 i5 上 tiny 大约 1.5—2× realtime;WebGPU(实验中)能拉到 4× 以上。
  • 优势:零网络往返、隐私可控、断网可用、不产生云端账单。
  • 劣势:首次下载模型体量大;中低端机只能跑 tiny,识别率明显劣于云端 large-v3。

4.3 云端:Faster-Whisper / WhisperX

  • Faster-Whisper:CTranslate2 推理 large-v3,A10/A100 上对单路 1× realtime 占用很低,单卡可并发数十路。
  • WhisperX:在 Faster-Whisper 之上叠加 wav2vec2 强制对齐,得到词级时间戳,适合做卡拉 OK 式字幕、口型同步。
  • 优势:识别率高、支持 large-v3、词级对齐、便于做翻译串联。
  • 劣势:每秒钟一次网络往返,p95 延迟受地理位置和网络抖动影响;账单与并发线性相关。

4.4 量化对比(实测中位数,仅作量级参考)

指标端侧 whisper.cpp tiny WASM端侧 base WebGPU云端 Faster-Whisper large-v3
首字延迟0.8—1.2 s0.6—1.0 s0.4—0.8 s(含网络 RTT)
稳态 partial 延迟1.5—2.5 s1.0—1.8 s0.6—1.2 s
WER(英文 LibriSpeech clean)约 7%—9%约 5%—6%约 2%—3%
WER(中文普通话)约 15%—20%约 10%—14%约 4%—7%
单路成本用户 CPU 时间用户 CPU/GPU 时间0.005—0.02 USD / 分钟(自建 GPU 摊销)
离线可用

4.5 何时端侧,何时云端

  • 隐私敏感(医疗、法务、企业内部)、客户端硬件可控(专属设备)→ 端侧。
  • 多语种、专业术语、用户自带词表、需要词级对齐 → 云端。
  • 大量并发但客户端配置参差不齐 → 云端,统一在 GPU 上摊薄成本。
  • 弱网或跨境(中国 ↔ 海外)→ 端侧或者就近部署 ASR 边缘节点,不要硬扛网络。

4.6 失败时的样子

  • 端侧 tiny 模型遇到强口音或低 SNR:输出大段无意义的常用词("the the the"),需要靠 VAD 能量阈值过滤掉。
  • 云端方案断网:必须有 partial 缓冲队列,重连后丢弃过期片段,不要回放过时字幕。
  • WebGPU 在 Safari 上覆盖率不足:必须做能力检测并回退到 WASM。

5. VAD、滑动窗口与 partial 修正

5.1 为什么必须 VAD

  • 静音段如果也送给 Whisper,模型会"幻觉"出"thank you for watching"之类的训练集尾部噪声。
  • 全程推理浪费算力:通话里实际有效语音通常占 30%—50%。
  • VAD 给出的语音段边界还能直接喂给前端 UI 做"对方正在说话"的指示。

5.2 选型

  • WebRTC 内置 VAD(libwebrtc 的 Vad):算力极低,但准确率一般,对低 SNR 场景容易漏检。
  • Silero VAD(ONNX,ort-web):约 1.8 MB,准确率显著好于 WebRTC VAD,可在 worker 里跑,单帧 1—2 ms。
  • 推荐组合:Silero VAD + 能量门限双重确认,避免单一模型在极端场景误判。

5.3 分段策略

interface VadSegmenterOptions {
minSpeechMs: number; // 低于这个时长的语音段直接丢弃,默认 250
maxSegmentMs: number; // 强制切段,避免一段超过 Whisper 30s 窗口,默认 15000
endpointSilenceMs: number; // 末尾静音达到这个值视为一段结束,默认 600
}

class VadSegmenter {
private buf: Float32Array[] = [];
private bufMs = 0;
private silenceMs = 0;
private speaking = false;

constructor(
private readonly opts: VadSegmenterOptions,
private readonly onSegment: (pcm16k: Float32Array) => void,
) {}

feed(frame: Float32Array, isSpeech: boolean, frameMs: number) {
if (isSpeech) {
this.speaking = true;
this.silenceMs = 0;
this.buf.push(frame);
this.bufMs += frameMs;
if (this.bufMs >= this.opts.maxSegmentMs) this.flush();
return;
}
if (this.speaking) {
this.buf.push(frame);
this.bufMs += frameMs;
this.silenceMs += frameMs;
if (this.silenceMs >= this.opts.endpointSilenceMs) this.flush();
}
}

private flush() {
if (this.bufMs >= this.opts.minSpeechMs) {
const total = this.buf.reduce((a, b) => a + b.length, 0);
const out = new Float32Array(total);
let off = 0;
for (const f of this.buf) { out.set(f, off); off += f.length; }
this.onSegment(out);
}
this.buf = [];
this.bufMs = 0;
this.silenceMs = 0;
this.speaking = false;
}
}

5.4 滑动窗口与 partial 锁定

直接每段 flush 后才解码,p95 延迟会到 1.5 秒以上,体感差。生产方案:

  • 维护一个 6—10 秒的滑动窗口缓冲,每 300—500 ms 触发一次"中途解码",输出 partial。
  • 把窗口左边沿前 N 秒(通常 3—4 秒)的文本作为"已锁定",不会再变;窗口右边沿的文本是"流动 partial",允许后续覆盖。
  • 锁定边界要落在 VAD 给出的语音段边界或词边界(WhisperX 才有词边界),不能落在中文字符的半个字 token 上。
  • 渲染层只有跨过锁定线的文本才会进入"最终字幕",未锁定的 partial 用浅色或不同样式区分。

5.5 失败模式

  • VAD 把咳嗽、笑声判为语音 → 模型输出乱码 → 用置信度(avg logprob)+ no_speech_prob 过滤。
  • 双人同时说话 → Whisper 只会识别能量更大的那一路,弱者的话被吞掉。需要做 source separation(如 Sepformer)或在 SFU 侧拆轨分别识别。
  • partial 抖动:同一句话连续 5 次输出,每次结尾都不一样。解决方法是只在两次连续 partial 文本前缀稳定时才向 UI 推送,避免闪烁。

6. 字幕渲染与多语言翻译

6.1 传输通道选型

通道适用场景不适用场景
RTCDataChannel(ordered=true, maxRetransmits=null)一对一/小房间 P2P,字幕作为元数据广播大房间,扇出复杂
SFU 旁路(自定义元数据 / mid 携带)大房间统一分发,需要 SFU 支持纯 mesh,无 SFU
WebVTT 内嵌(HLS/DASH 直播)WHIP→ 转 HLS 的低延迟直播互动通话,WebVTT 段切片粒度太大
业务侧 WebSocket字幕需要落库、回放、检索强 P2P 私密通话

6.2 字幕消息结构

interface SubtitleMessage {
/** 单调递增的字幕段 id,便于覆盖与去重 */
segId: number;
/** 该段是否已锁定,不再变化 */
final: boolean;
/** 说话人 id,对应 SFU 的 mid 或业务 userId */
speakerId: string;
/** UTC 毫秒,发送侧 ASR 完成时刻 */
ts: number;
/** 主语言文本 */
text: string;
/** 译文,按 BCP-47 语言标签 */
translations?: Record<string, string>;
/** 词级时间戳(可选,WhisperX) */
words?: Array<{ w: string; s: number; e: number }>;
}

6.3 翻译流水线

  • 串联:ASR → 文本队列 → 翻译模型(NLLB、M2M-100、商用 API)→ DataChannel。
  • 翻译只应该跑在 final 字幕上,不要翻 partial:partial 经常半句不通顺,翻译延迟也叠加上去。
  • 中转语言要明确:中文 → 英文 → 西班牙语 中转会大量语义损耗,宁可直接训练或选择支持目标语对的模型。
  • 关键术语词表(如医学/法律名词)通过 prompt 注入或自定义词典覆盖,模型本体很难"专项强化"。

6.4 渲染层

  • 字幕容器用 fixed positioning,不要塞进音视频元素本身,避免和 picture-in-picture 冲突。
  • 每段字幕单独一个节点,长度受限(约 42 字符/行 × 2 行,CEA-608 字幕惯例);超过则做滚动或截断。
  • partial 用 CSS opacity: 0.6 或不同颜色,final 完成后做一次 cross-fade,不要直接替换文本——会触发屏幕阅读器重复朗读。
  • 渲染节奏:requestAnimationFrame 节流到 30 Hz 足够,partial 推送频率高于此时合并最后一次。

6.5 失败时的样子

  • DataChannel 在 SFU 转发路径上 packet loss → 字幕跳段。需要在消息里带 segId,接收端检测到不连续就请求重传或直接丢弃。
  • 翻译服务限速:本端字幕正常,远端 UI 缺译文,需要降级为只显示原文 + "翻译中…"。

7. 性能预算与硬件画像

7.1 4 核桌面(Intel i5-1135G7 量级)

模块CPU 占用内存备注
WebRTC 音视频本身8%—12%80—120 MB含 Opus、AEC、AGC
RNNoise WASM SIMD1%—2%5 MB单 worker
Silero VAD WASM1%8 MB32 ms 跳步
whisper.cpp tiny q5_1 WASM18%—25%90 MB实时 partial 周期 400 ms
主线程渲染 + UI5%—10%React 中等复杂度
合计33%—50%约 200 MB留出 50% 余量给浏览器其他工作
  • 首字延迟:约 0.9 秒(含 VAD 200 ms + 模型推理 600 ms + 渲染 100 ms)。
  • 稳态 partial 延迟:约 1.6 秒。

7.2 中端 Android(Snapdragon 7 Gen 1)

  • RNNoise + WebRTC 本身合计 12%—15% CPU,可以接受。
  • DeepFilterNet 3 单独 6%—10%,再叠加端侧 whisper-tiny WASM 会把整机推到 60%+,发热明显,建议改为云端 ASR。
  • WebGPU 支持参差不齐,不要把推理后端硬绑定在 WebGPU 上。

7.3 何时主动降级

  • navigator.hardwareConcurrency &lt; 4deviceMemory &lt; 4:禁用 DFN,降到 RNNoise;禁用端侧 Whisper。
  • getStats()audio outboundRtpnackCount 持续上升、jitterBufferDelay > 300 ms:暂停字幕推理,把 CPU 让给音视频本身。
  • 笔记本电池模式(navigator.getBattery())+ 低电量:把 partial 周期从 400 ms 拉到 1 s。

8. 反模式

下面这些组合在 PR 评审里直接打回。

  • 在主线程加载并执行 WASM 模型。WebAssembly.instantiateStreaming 必须在 worker 里调用,否则 React 渲染、动画、视频解码会被周期性卡顿撞上。
  • 不做 VAD,把麦克风原始流 24×7 灌进 Whisper。账单和电池都会爆,并且模型会反复产生幻觉文本。
  • ScriptProcessorNode 里做降噪。该节点已废弃,无法保证实时性,移动端必丢字。
  • 字幕直接拼接到 textContent 或用 innerHTML 替换。partial 频繁变化时屏幕阅读器会重复读、移动端会触发回流抖动。应该用受控的 React/Vue 组件 + key 跟随 segId
  • 不做 partial 锁定。用户看到字幕"长出来又改回去",体感像 GPT 流式但是更短更怪,专业场景(庭审、医疗)完全不可用。
  • 把 RNNoise 和系统自带 AEC/NS 同时打开,且都开到最强。语音会被磨得发飘,对端听感差于不开降噪。
  • 翻译走 partial。partial 半句不通,翻译输出常常完全错位;只翻 final。
  • DataChannel ordered=true, maxRetransmits=null(默认)下塞高频 partial 文本。一旦丢包重传,字幕会阻塞数秒。partial 应该走 ordered=false, maxRetransmits=0 的"不可靠快递",final 才走可靠通道。
  • 把模型文件直接打进主 bundle。tiny 都有几十 MB,应该走独立 CDN 路径、按需下载、加 Cache-Control: immutable
  • 把云端 ASR API key 直接塞前端代码。必须走签名换 token,token 短期有效,并按用户限流。

9. 工程落地清单

  • HTTP 响应头开启 COOP/COEP,验证 crossOriginIsolated === true
  • AudioContext 采样率显式 48 kHz,并做 16 kHz 重采样后再喂 Whisper。
  • RNNoise/DFN WASM 用 -msimd128 -O3 编译,验证 WebAssembly.validate 检测 SIMD。
  • Worker 加载使用 type: 'module',便于 Tree shaking。
  • Silero VAD 与 Whisper 解码放同一 worker 减少 SAB 跨线程调度。
  • partial 推送频率 ≤ 5 Hz;final 推送一次性提交。
  • 字幕消息走专用 DataChannel(label subtitle/v1);partial 与 final 分两条 channel:partial unreliable、final reliable。
  • 接收端按 segId 单调递增,乱序丢弃,缺失打洞 < 3 s 则忽略。
  • 客户端能力检测:CPU 核数、设备内存、navigator.connection.effectiveTypegetBattery(),给出降级矩阵。
  • 监控指标:partial 平均延迟、final 平均延迟、WER 抽样(用对照样本)、降噪开启率、降级触发率。

10. 权威资料

  • Valin, J.-M. A Hybrid DSP/Deep Learning Approach to Real-Time Full-Band Speech Enhancement. MMSP 2018. RNNoise 原始论文:arXiv:1709.08243
  • Schröter, H. et al. DeepFilterNet: A Low Complexity Speech Enhancement Framework for Full-Band Audio based on Deep Filtering. ICASSP 2022。
  • Schröter, H. et al. DeepFilterNet2: Towards Real-Time Speech Enhancement on Embedded Devices for Full-Band Audio. IWAENC 2022。
  • Schröter, H. et al. DeepFilterNet3. Interspeech 2023。
  • Radford, A. et al. Robust Speech Recognition via Large-Scale Weak Supervision. OpenAI Whisper 论文,2022:arXiv:2212.04356
  • Bain, M. et al. WhisperX: Time-Accurate Speech Transcription of Long-Form Audio. Interspeech 2023。
  • Silero Team. Silero VAD. GitHub: snakers4/silero-vad。
  • W3C. Web Audio API. Recommendation:https://www.w3.org/TR/webaudio/
  • W3C. AudioWorklet. https://www.w3.org/TR/webaudio/#AudioWorklet
  • WHATWG. SharedArrayBuffer and cross-origin isolation. MDN 与 HTML spec 中的相关章节。
  • W3C. WebRTC 1.0: Real-time Communication Between Browsers. Recommendation。
  • W3C. WebCodecs. Working Draft(与 AudioWorklet 配合时的参考)。
  • ggerganov / whisper.cpp 与 SYSTRAN / faster-whisper 项目主页。

核对日期:2026-06-22。