会议静音提醒

用 80 行 Web Audio API 写一个会议静音提醒工具

发布:

拆解如何用 getDisplayMedia 捕获标签页音频、AnalyserNode 计算 RMS 电平,并在会议陷入沉默时触发多通道提醒——全部在浏览器里完成,没有后端。

如果你曾经熬完一场漫长的远程会议、结果会议结束 5 分钟后才发现大家早就散了——这篇文章就是为你写的。市面上不缺会议倒计时插件,但固定时长的计时器并不能解决主持人提前或延后散会的情况。你真正想要的是:“当对话陷入沉默一段时间后提醒我。”

会议静音提醒 就是干这件事的小工具:

  1. 你把会议标签页共享给页面;
  2. 页面监听这个标签页的音频并持续读取音量;
  3. 当音量低于阈值且持续达到你设定的时长时,触发四个通道的提醒:页面 modal、系统通知、合成警报音、标签页标题闪烁。

核心实现大约 80 行 TypeScript,全部在客户端运行,会议音频从不离开浏览器。本文走一遍它的实现思路。

用到的两个 Web API

只需要两个浏览器 API:

不需要任何第三方库,没有后端,没有录音。

捕获标签页音频

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: { displaySurface: 'browser' },
  audio: true,
  // 非标准但 Chromium 系都支持——
  // 让 Chrome 的选择器默认停在「标签页」分类。
  selfBrowserSurface: 'exclude',
  systemAudio: 'include',
});

几个不那么显然的细节:

不能只要 audio: true 规范要求必须同时声明 video,即使我们不需要视频。两个都取下来,然后立刻停掉视频轨道节省资源:

stream.getVideoTracks().forEach((track) => track.stop());

音频捕获是 opt-in 的。 Chrome 的选择框底部有一个”共享标签页音频”复选框。用户不勾,你拿到的 stream 就会有 getAudioTracks().length === 0。代码里要检测并提示重新共享:

const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
  stream.getTracks().forEach((track) => track.stop());
  throw new Error('没有捕获到音频——共享时请勾选"共享标签页音频"。');
}

Firefox 不支持。 Firefox 的屏幕共享对话框里根本没有”共享标签页音频”这个选项。需要做特性检测(navigator.mediaDevices?.getDisplayMedia)并优雅降级。

RMS → dB

拿到 MediaStream 之后,接进 Web Audio 图,用定时器读振幅:

const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
// 注意:我们 *不* 把 analyser 连到 ctx.destination。
// 我们只想 *测量* 音频,不想把它回放到用户的扬声器里。

const buffer = new Float32Array(analyser.fftSize);

setInterval(() => {
  analyser.getFloatTimeDomainData(buffer);
  let sumSquares = 0;
  for (let i = 0; i < buffer.length; i++) {
    sumSquares += buffer[i] ** 2;
  }
  const rms = Math.sqrt(sumSquares / buffer.length);
  const db = rms > 0 ? 20 * Math.log10(rms) : -100;
  // db 的范围大致是 [-100, 0]
}, 200);

几个要点:

后台标签页节流(以及为什么不影响我们)

当用户切到其它标签页时,setInterval 会被节流到最低 1 秒一次。这听起来对实时音频监控不利——但对我们这个场景不构成问题:

实际效果是:你可以让会议在一个标签页,让本工具在另一个标签页,再切到第三个标签页干别的,静音检测照常生效。

合成警报音

检测到静音后我们要抓住用户注意力。最低成本的办法是放一段警报音——但我们不想引入音频资源(多一次请求、多一份构建产物)。Web Audio API 可以免费合成一段:

class AlarmSound {
  private ctx: AudioContext | null = null;
  private oscillator: OscillatorNode | null = null;
  private gain: GainNode | null = null;
  private timer: number | null = null;

  start() {
    this.ctx = new AudioContext();
    this.gain = this.ctx.createGain();
    this.gain.gain.value = 0;
    this.gain.connect(this.ctx.destination);

    this.oscillator = this.ctx.createOscillator();
    this.oscillator.type = 'square';
    this.oscillator.frequency.value = 880;
    this.oscillator.connect(this.gain);
    this.oscillator.start();

    const beep = () => {
      if (!this.ctx || !this.gain) return;
      const t = this.ctx.currentTime;
      const g = this.gain.gain;
      g.cancelScheduledValues(t);
      g.setValueAtTime(0, t);
      g.linearRampToValueAtTime(0.25, t + 0.02);  // attack
      g.setValueAtTime(0.25, t + 0.18);            // sustain
      g.linearRampToValueAtTime(0, t + 0.22);      // release
    };

    beep();
    this.timer = window.setInterval(beep, 600);
  }

  stop() {
    if (this.timer !== null) clearInterval(this.timer);
    try { this.oscillator?.stop(); } catch {}
    this.ctx?.close();
  }
}

880 Hz 的方波振荡器够亮,能穿透用户当前在做的其他事情。GainNode 给每一声鸣响套一个小小的 ADSR 包络,避免它听起来像一个卡住的连续音。用 setInterval(beep, 600) 循环这个包络,就得到了经典的”嘀—嘀—嘀”警报模式。

三重提醒

声音单独靠不住——用户的扬声器可能静音,或者输出设备不在听这个 tab。所以我们同时触发另外三个通道:

  1. 页面内模态框 —— 全屏覆盖一切,必须显式关闭。
  2. Notification —— 浏览器的原生系统通知,恰恰适合用户切到别处的场景。权限必须在用户手势上下文中请求,所以我们把它放在调用 getDisplayMedia 的同一个点击处理器里,和屏幕共享提示一起弹。
  3. 标签页标题闪烁 —— 每 800 毫秒在原标题和 ”⚠ 会议可能已经结束” 之间切换。即使页面在后台,浏览器标签栏里也能看到。

隐私姿态

值得停下来强调一下:会议音频自始至终没有离开过浏览器。

对用户来说这是比典型会议工具更强的隐私承诺:没有任何东西能丢失,因为本来就什么都没捕获。

它也让我们能把整站当作纯静态站点部署(我们这里用的是 Cloudflare Workers Static Assets),完全没有服务端运行时。

试一下

如果你用这个模式做了类似的”X 停下来就叫醒我”小工具,欢迎在 issue tracker 告诉我一声。