AB
AiBoss
チュートリアル

用 Web Audio API 在浏览器里测量英语口语的发话延迟与停顿比例

チュートリアル

用 Web Audio API 在浏览器里测量英语口语的发话延迟与停顿比例

把「开口慢、句中停顿多」这类口语问题变成可测量的数字:用 Web Audio API 的 AnalyserNode 在浏览器本地做静音检测,算出首次发话延迟与停顿帧占比,再配合一套按认知负荷分层的训练顺序。本文给出完整的检测类实现、参数含义、指标口径与一个可跑通的最小示例。

很多人练英语口语时卡在同一个地方:不是不会说,而是开口之前要愣上好几秒,说出口之后句子中间又不断出现空白。这类问题靠「多背单词」解决不了,因为它属于产出速度与自动化程度的问题,而不是知识量的问题。本教程讲的做法是:先用浏览器里的 Web Audio API 把「首次发话延迟」和「句中停顿比例」这两个量测出来,让问题变成数字;再按一套由易到难的训练顺序去练,练完再测一次,看数字有没有动。整套测量完全在本地内存里完成,音频不上传,适合准备英语面试、国际会议发言、跨国团队例会的人自己搭一套来用。文中涉及的工具版本、浏览器行为与任何价格信息,请以各项目官网当前说明为准。

准备工作

环境与前置条件

  • 一个支持 Web Audio API 的现代浏览器。AudioContext、AnalyserNode、getFloatTimeDomainData 都是较早期就进入标准的能力,主流桌面浏览器均可使用。
  • 一个可以跑本地静态页面的环境。因为要申请麦克风权限,用 file:// 直接打开页面在部分浏览器上会被拒绝,建议起一个本地静态服务器,例如用任意静态服务器工具把目录挂到 localhost 上。
  • 麦克风设备,以及操作系统层面已经授予浏览器麦克风权限。
  • 页面必须运行在安全上下文(HTTPS 或 localhost)下,否则 getUserMedia 不可用。

需要先想清楚的两个口径

动手写代码之前,先把要测的东西定义清楚,否则数字没有可比性。

  • 首次发话延迟:从「开始计时」到「检测到第一帧有效语音」之间的毫秒数。计时起点可以是你按下开始按钮的瞬间,也可以是提问音频播放结束的瞬间。两种口径不能混用,前后两次测量必须用同一种。
  • 停顿比例:在整段录音里,被判为静音的帧数占总帧数的百分比。注意这是按帧统计,不是按时间加权,所以帧的采样节奏要稳定。

关于阈值这个关键参数

检测静音靠的是音量。代码里用均方根(RMS)作为音量指标,再设一个阈值:RMS 低于阈值就算静音帧,高于阈值就算语音帧。这个阈值没有通用最优值,它取决于你的麦克风增益、房间底噪和说话音量。阈值定得太高,会把轻声说话误判成静音,延迟被算大;定得太低,环境噪声会被当成语音,延迟被算小。建议在正式测量前先录一段「不说话」的环境底噪,观察 RMS 大致落在什么区间,再把阈值设在底噪之上、正常说话之下。

操作步骤

第一步:申请麦克风并建立音频图

用 getUserMedia 拿到音频流,创建 AudioContext,把流接成 MediaStreamSource,再连到一个 AnalyserNode。AnalyserNode 是整件事的核心:它不产生声音,只是让你能持续读取最近一段时域采样。

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);

fftSize 决定了每次读取的采样窗口大小,这里取 512。窗口越小,时间分辨率越高,但单帧的音量估计越容易被瞬时波动影响;窗口越大,估计越平滑,但对短促的起音反应会变迟钝。做发话延迟测量时,512 是一个常用的折中值。

第二步:逐帧读取时域数据并算 RMS

用 getFloatTimeDomainData 把当前窗口的采样读进一个 Float32Array,然后求均方根。RMS 是这一帧的音量估计值。

const buffer = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(buffer);

let sumSquares = 0;
for (let i = 0; i < buffer.length; i++) {
  sumSquares += buffer[i] * buffer[i];
}
const rms = Math.sqrt(sumSquares / buffer.length);

注意这里用的是时域数据而不是频域数据。频域数据适合做音色、音高分析,但判断「有没有人在说话」用整体能量就够了,时域 RMS 计算量小、含义直观。

第三步:用 requestAnimationFrame 驱动循环

把上面这段读取逻辑放进一个循环里反复执行。用 requestAnimationFrame 而不是 setInterval,可以让采样节奏跟着浏览器的渲染帧走,页面切到后台时会自动降频,不会白白占用资源。

function analyze() {
  if (!isListening || !analyser) return;

  const buffer = new Float32Array(analyser.fftSize);
  analyser.getFloatTimeDomainData(buffer);

  let sumSquares = 0;
  for (let i = 0; i < buffer.length; i++) {
    sumSquares += buffer[i] * buffer[i];
  }
  const rms = Math.sqrt(sumSquares / buffer.length);

  totalFrames++;
  if (rms < thresholdRms) {
    silenceFrames++;
  } else if (firstSpeechTime === 0) {
    firstSpeechTime = performance.now();
  }

  requestAnimationFrame(analyze);
}

计时统一用 performance.now(),它返回的是高精度单调时钟,不受系统时间调整影响,适合做毫秒级间隔测量。

第四步:记录首次发话时刻

关键点是 firstSpeechTime 只在第一次越过阈值时写入一次。用 firstSpeechTime === 0 作为「还没记过」的标记,后续所有语音帧都不再改写它。如果写成每次语音帧都赋值,那测出来的就变成「最后一次说话的时刻」,指标含义完全变了。

第五步:停止并汇总指标

停止时把监听标志置为 false,然后计算三个值:总时长、首次发话延迟、停顿比例。

function stop() {
  isListening = false;

  const totalDuration = performance.now() - startTime;
  const latency = firstSpeechTime > 0
    ? firstSpeechTime - startTime
    : totalDuration;
  const silenceRatio = totalFrames > 0
    ? (silenceFrames / totalFrames) * 100
    : 0;

  return {
    latencyMs: latency,
    totalDurationMs: totalDuration,
    silenceRatio: silenceRatio
  };
}

这里有一个必须处理的边界情况:如果整段录音里一帧语音都没检测到,firstSpeechTime 仍然是 0。此时把延迟记为总时长,等于告诉使用者「这段时间内没有开口」,而不是给出一个虚假的小数字。

第六步:把测量结果接进训练循环

测量本身不产生进步,它只是把进步变成可见的数字。素材给出的训练顺序按认知负荷从低到高分成三档,每档内部再细分若干步。可以把它理解成一条状态机:低负荷的步骤稳定达标后再进入下一档。

档位训练重点典型步骤
第一档把话说长、把音发准用 Because / However 把一句话扩成三句;不看文字直接出声;用听写找出连读和吞音;改错后遮住范文自己重说一遍
第二档限时开口、按框架组织十秒倒计时内开口;用 STAR 或 PREP 框架组织内容;盯着停顿指标说话;不给题目自己连续说一分钟
第三档快速应答、扛住追问变速跟读;五秒内回应连续提问;应对多轮追加的 Why / How 追问;做一次完整时长的模拟

这套顺序背后的思路是:知识性的内容(语法、词汇)适合打散混着练,而流利度(开口速度、句中停顿)适合把同一批句子固定下来反复练。原因是流利度依赖的是把发音动作变成自动化,重复同一批材料能直接降低检索负担;而它的常见副作用——只会说练过的那几句——在面试、例会、答辩这类场景里影响很小,因为这些场景的问答内容本来就可以提前预判。所以本教程建议:把你要在真实场合说的那批句子固定下来,反复练,同时用上面的检测代码盯着延迟和停顿两个数字。

一个完整示例

下面是一个可以整体复制使用的检测类,包含启动、循环分析、停止汇总三部分。

export class SpeechSilenceDetector {
  audioContext = null;
  analyser = null;
  startTime = 0;
  firstSpeechTime = 0;
  isListening = false;
  silenceFrames = 0;
  totalFrames = 0;

  constructor(thresholdRms = 0.02) {
    this.thresholdRms = thresholdRms;
  }

  async start(stream) {
    this.audioContext = new AudioContext();
    const source = this.audioContext.createMediaStreamSource(stream);
    this.analyser = this.audioContext.createAnalyser();
    this.analyser.fftSize = 512;
    source.connect(this.analyser);

    this.startTime = performance.now();
    this.firstSpeechTime = 0;
    this.isListening = true;
    this.silenceFrames = 0;
    this.totalFrames = 0;

    this.analyze();
  }

  analyze() {
    if (!this.isListening || !this.analyser) return;

    const buffer = new Float32Array(this.analyser.fftSize);
    this.analyser.getFloatTimeDomainData(buffer);

    let sumSquares = 0;
    for (let i = 0; i < buffer.length; i++) {
      sumSquares += buffer[i] * buffer[i];
    }
    const rms = Math.sqrt(sumSquares / buffer.length);

    this.totalFrames++;
    if (rms < this.thresholdRms) {
      this.silenceFrames++;
    } else if (this.firstSpeechTime === 0) {
      this.firstSpeechTime = performance.now();
    }

    requestAnimationFrame(() => this.analyze());
  }

  stop() {
    this.isListening = false;

    const totalDuration = performance.now() - this.startTime;
    const latency = this.firstSpeechTime > 0
      ? this.firstSpeechTime - this.startTime
      : totalDuration;
    const silenceRatio = this.totalFrames > 0
      ? (this.silenceFrames / this.totalFrames) * 100
      : 0;

    return {
      latencyMs: latency,
      totalDurationMs: totalDuration,
      silenceRatio: silenceRatio
    };
  }
}

调用方式如下:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const detector = new SpeechSilenceDetector(0.02);

await detector.start(stream);

// 这里让使用者开始说话,例如播放一道题目后开始计时
// 到达预定时间后调用:
const result = detector.stop();
console.log('首次发话延迟(ms):', result.latencyMs.toFixed(1));
console.log('总时长(ms):', result.totalDurationMs.toFixed(1));
console.log('停顿比例(%):', result.silenceRatio.toFixed(1));

// 用完记得释放麦克风
stream.getTracks().forEach(t => t.stop());

跑通之后你会得到三个数字。建议的做法是:同一批题目、同一个阈值、同一种计时起点,先测一次作为基线,练一段时间后再测一次,比较两个数字的变化。不要在不同阈值、不同起点之间横向比较,那样得出的差异主要来自口径而不是能力。

把指标做成可比较的记录

单次数字意义有限,建议按下面的字段做一张简单的记录表,每次测量追加一行:

字段含义注意事项
日期测量时间用于观察趋势
阈值本次使用的 RMS 阈值换阈值必须重新测基线
计时起点按下开始 / 题目播放结束前后必须一致
首次发话延迟毫秒越小越好
停顿比例百分比越小越好
框架完成度是否用三句完整作答人工判定

注意事项

  • 阈值必须按环境校准。同一个阈值换到不同房间、不同麦克风上结果会明显不同。换环境后要重新观察底噪再定阈值。
  • 帧计数不是时间加权。停顿比例是按帧数算的,如果采样节奏不稳定(例如页面被切到后台导致 requestAnimationFrame 降频),这个比例会失真。测量时保持页面在前台。
  • 首次发话延迟的口径要固定。从按下开始算和从题目播放结束算,两者可能差出好几秒,混用会让数据完全失去可比性。
  • 没有检测到语音时要如实处理。把延迟记为总时长,而不是记为零或跳过,否则会污染统计。
  • 音频留在本地。这套实现只读取内存中的采样数据,不涉及上传。但如果你自己扩展成录音并保存,就要自行考虑存储与隐私问题。
  • 浏览器权限与安全上下文。非 HTTPS 且非 localhost 的页面拿不到麦克风,这是浏览器的强制要求,不是代码问题。
  • 指标只是指标。延迟和停顿比例反映的是产出速度,不反映内容是否切题、逻辑是否成立。训练时两个维度都要看。
  • 训练顺序不要跳档。低负荷步骤还没稳定就去做限时应答,容易把注意力全耗在组织语言上,反而强化了卡顿习惯。
  • 易变信息以官网为准。浏览器 API 的行为细节、各工具版本、任何收费方案都可能变化,实际使用前请查阅对应项目的当前文档。