Skip to content

设备权限、录音容器与语音端点 ​

本节将本地文件入口扩展到浏览器采集。先认识JavaScript Promise和事件回调;录音只保存在本机页面,不上传到模型。设备实际权限、麦克风质量与回声消除需在目标设备另测。

开始、停止与迟到的授权 ​

将下面保存为 device_lab.html,在该目录运行 python3 -m http.server 8765 --bind 127.0.0.1,浏览器打开localhost:8765/device_lab.html。点击开始后才请求麦克风。拒绝权限会提示使用已有音频文件;如果在权限框未处理时点击停止,迟到取得的track会立刻释放,不意外继续录音。

html
<!doctype html>
<meta charset="utf-8">
<title>本地录音与释放</title>
<button id="start">开始本地录音</button>
<button id="stop" disabled>停止</button>
<p id="status" role="status">录音不会上传</p>
<audio id="audio" controls></audio>
<script>
const start = document.querySelector('#start');
const stop = document.querySelector('#stop');
const status = document.querySelector('#status');
const audio = document.querySelector('#audio');
let current = null, url = null, closed = false;
function stopOwn(session) {
  session.cancelled = true;
  if (session.recorder?.state !== 'inactive') session.recorder?.stop();
  session.stream?.getTracks().forEach(track => track.stop());
}
function release() {
  if (current) stopOwn(current);
  stop.disabled = true;
  start.disabled = closed;
}
start.onclick = async () => {
  if (closed) return;
  const session = {stream:null, recorder:null, chunks:[], cancelled:false, failed:false};
  current = session;
  start.disabled = true; stop.disabled = false;
  status.textContent = '等待设备许可';
  try {
    const acquired = await navigator.mediaDevices.getUserMedia({audio:true, video:false});
    session.stream = acquired;
    if (closed || current !== session || session.cancelled) { stopOwn(session); return; }
    const recorder = new MediaRecorder(acquired);
    session.recorder = recorder;
    recorder.ondataavailable = event => {
      if (!closed && current === session && !session.failed && event.data.size)
        session.chunks.push(event.data);
    };
    recorder.onstop = () => {
      // stop()异步回调只发布自己的结果,不能碰新录音或已关闭页面。
      if (closed || current !== session || session.failed) return;
      if (url) URL.revokeObjectURL(url);
      const blob = new Blob(session.chunks, {type:recorder.mimeType});
      session.chunks = [];
      url = URL.createObjectURL(blob); audio.src = url;
      status.textContent = `已停止;${recorder.mimeType},${blob.size}字节`;
    };
    recorder.onerror = () => {
      session.failed = true; session.chunks = []; stopOwn(session);
      if (closed || current !== session) return;
      release(); status.textContent = '录音失败,请重试或使用已有文件';
    };
    recorder.start(200); status.textContent = '本地录音中';
  } catch (error) {
    session.failed = true; stopOwn(session);
    if (closed || current !== session) return;
    release(); status.textContent = `${error.name}:可使用已有音频文件`;
  }
};
stop.onclick = release;
addEventListener('pagehide', () => {
  closed = true; release(); current = null;
  if (url) URL.revokeObjectURL(url);
  url = null;
});
</script>

每次开始创建独立session。停止会释放这一轮的track,但最后dataavailable/onstop仍可随后到达;只有仍属于current的会话能发布Blob。新一轮开始会替换current,旧error只清理自己的stream,不会关闭新录音。pagehide先置closed,防止迟到回调又创建Blob URL。这里仍将整个录音保存在内存,适合短录音;长录音需设置时长/字节预算或按同次容器流持久化,不能无限录制。

MediaRecorder输出的是浏览器选择的容器/编码,mimeType可能是WebM/Opus等,不能把这些字节直接发给要求PCM16的接口。先上传到受控服务解码,再按第一章重采样并验证实际声道/采样率,或用AudioWorklet取得PCM。Blob每200ms触发不保证时间精度,也不保证每块可独立解码,应按同次录音顺序保存。此页面未实现浏览器实时上传,避免把录音分片误当作完整实时协议。

WebRTC把音频track交给媒体栈,包含媒体协商和实时传输机制;WebSocket需要应用处理编码、时间与队列;SSE适合服务端事件下行,本身不承载双向麦克风通道。若使用云WebRTC,服务端获取短效受限凭据,浏览器不持有长期密钥。屏幕捕获需要独立的getDisplayMedia用户选择,不应把麦克风许可当作屏幕许可。

参考设备许可与安全上下文、录音mimeType。

能量门限不等于理解说话 ​

下面用真实数值采样计算RMS,再以连续静音块决定端点。保存 energy_gate.py,在第一章numpy环境运行。纯音只是可控信号;此实验检查门限和静音计数,不证明真实语音VAD准确率。

python
import numpy as np
rate=16000;size=320  # 每块20ms
silence=np.zeros(size)
tone=.2*np.sin(2*np.pi*440*np.arange(size)/rate)
def boundaries(blocks,threshold=.05,hangover=3):
    speaking=False;quiet=0;events=[]
    for i,block in enumerate(blocks):
        if len(block)!=size:raise ValueError('partial frame')
        active=np.sqrt(np.mean(np.asarray(block,dtype=float)**2))>=threshold
        if active:
            quiet=0
            if not speaking:events.append(('start',i*20));speaking=True
        elif speaking:
            quiet+=1
            if quiet>=hangover:
                events.append(('stop',(i+1)*20));speaking=False;quiet=0
    return events
assert boundaries([silence,tone,tone,silence,silence,silence])==[('start',20),('stop',120)]
assert boundaries([silence]*6)==[]
assert boundaries([tone]*6)==[('start',0)]
print('energy gate and hangover passed')

120ms才发stop,已经包含60ms静音等待;不能将端点时间直接当最后一个语音样本时间。持续纯音也被当成说话,说明能量门限无法区分音乐与人声。真实VAD模型改善分类,仍需处理声学回声、双人重叠和设备噪声;语义端点还考虑一句话是否说完。按键说话可以作为可解释的备用交互。

练习 ​

把hangover改为2,预测stop应在100ms。将纯音幅度降到0.01,解释为什么它不会触发,以及提高麦克风增益为何会改变结果。页面中先点开始再立刻停止,随后才允许设备,验收浏览器麦克风指示灯不应持续亮;这是需要真实设备的手动检查,本次未执行。再测试停止后立即重启:延迟到来的旧onstop/onerror不得修改第二次录音;页面离开后任何回调都不得创建新URL。答案提供不依赖麦克风的可控事件顺序测试,但不能代替设备权限验收。

本章答案

用心记录每一步成长