第十章:实时音频(WebRTC & MediaStream)—— 让声音实时流动
前九章我们处理的音频都是"静态"的——文件、流媒体、本地处理。这一章进入另一个维度:实时音频。麦克风采集、噪声消除、实时传输、多人通话……这些场景背后是 WebRTC 和 MediaStream 这套完整的实时通信技术栈。理解它,你才能真正驾驭浏览器里的"实时声音"。
一、MediaStream:实时音频的数据容器
MediaStream 是实时音频(和视频)数据的容器对象,由一组 MediaStreamTrack 组成。每个 Track 代表一路独立的媒体轨道(一路麦克风音频、一路摄像头视频等)。
MediaStream
├── AudioTrack (MediaStreamTrack, kind="audio")
│ ├── 来源:麦克风 / Web Audio / 屏幕录制
│ └── 状态:enabled / muted / readyState
└── VideoTrack (MediaStreamTrack, kind="video")
获取麦克风输入:getUserMedia
// 基础用法:请求麦克风权限
async function getMicrophoneStream(constraints = {}) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
// 采样率(浏览器可能不完全遵守)
sampleRate: 48000,
// 声道数
channelCount: 1,
// 回声消除(浏览器内置,WebRTC 场景必开)
echoCancellation: true,
// 噪声抑制
noiseSuppression: true,
// 自动增益控制(让声音音量自动稳定)
autoGainControl: true,
// 延迟(低延迟模式,部分浏览器支持)
latency: 0.01,
},
video: false,
});
console.log('麦克风权限已获取');
console.log('音频轨道:', stream.getAudioTracks()[0].label);
return stream;
} catch (err) {
// 常见错误处理
const errorMessages = {
NotAllowedError: '用户拒绝了麦克风权限',
NotFoundError: '未找到麦克风设备',
NotReadableError: '麦克风被其他应用占用',
OverconstrainedError: '设备不满足约束条件',
};
throw new Error(errorMessages[err.name] || `未知错误: `);
}
}枚举可用音频设备
async function listAudioDevices() {
// 必须先获取权限,否则 label 字段为空
await navigator.mediaDevices.getUserMedia({ audio: true })
.then(s => s.getTracks().forEach(t => t.stop())); // 立即释放
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
const audioOutputs = devices.filter(d => d.kind === 'audiooutput');
console.log('输入设备(麦克风):');
audioInputs.forEach(d => console.log(` - [] `));
console.log('输出设备(扬声器):');
audioOutputs.forEach(d => console.log(` - [] `));
return { audioInputs, audioOutputs };
}
// 切换麦克风设备
async function switchMicrophone(deviceId) {
return navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId } },
});
}
// 监听设备变化(插拔耳机等)
navigator.mediaDevices.addEventListener('devicechange', async () => {
console.log('设备列表发生变化');
const { audioInputs } = await listAudioDevices();
updateDeviceSelector(audioInputs);
});MediaStreamTrack 控制
const stream = await getMicrophoneStream();
const track = stream.getAudioTracks()[0];
// 静音(不是停止,只是不发送数据)
track.enabled = false;
// 恢复
track.enabled = true;
// 获取实际约束(浏览器实际使用的参数)
const settings = track.getSettings();
console.log('实际采样率:', settings.sampleRate);
console.log('实际声道数:', settings.channelCount);
console.log('回声消除:', settings.echoCancellation);
// 动态修改约束
await track.applyConstraints({
echoCancellation: false, // 关闭回声消除(音乐场景)
noiseSuppression: false, // 关闭噪声抑制(保留环境音)
});
// 停止轨道(释放麦克风,摄像头指示灯熄灭)
track.stop();二、Web Audio API 与 MediaStream 的结合
麦克风流可以直接接入 Web Audio 处理链,实现实时音频处理:
class RealtimeAudioProcessor {
constructor() {
this.audioCtx = new AudioContext({ sampleRate: 48000 });
this.stream = null;
this.source = null;
}
async start() {
// 1. 获取麦克风流
this.stream = await getMicrophoneStream();
// 2. 创建 MediaStream 源节点
this.source = this.audioCtx.createMediaStreamSource(this.stream);
// 3. 构建处理链
const gainNode = this.audioCtx.createGain();
const analyser = this.audioCtx.createAnalyser();
const compressor = this.audioCtx.createDynamicsCompressor();
gainNode.gain.value = 1.2;
analyser.fftSize = 2048;
// 4. 连接处理链
this.source
.connect(gainNode)
.connect(compressor)
.connect(analyser);
// 注意:麦克风直接连 destination 会产生回声!
// 除非使用耳机,否则不要把麦克风直接连到 destination
// analyser.connect(this.audioCtx.destination);
// 5. 将处理后的音频输出为新的 MediaStream(用于录制或 WebRTC)
const dest = this.audioCtx.createMediaStreamDestination();
analyser.connect(dest);
this.processedStream = dest.stream;
return this.processedStream;
}
stop() {
this.stream?.getTracks().forEach(t => t.stop());
this.source?.disconnect();
}
}三、浏览器录音:MediaRecorder
MediaRecorder 是浏览器内置的录音/录像 API,直接接受 MediaStream 作为输入:
class AudioRecorder {
constructor() {
this.recorder = null;
this.chunks = [];
this.stream = null;
this.startTime = 0;
}
// 检测最优录音格式
static getBestMimeType() {
const types = [
'audio/webm; codecs=opus',
'audio/webm',
'audio/ogg; codecs=opus',
'audio/mp4',
];
return types.find(t => MediaRecorder.isTypeSupported(t)) || '';
}
async start(options = {}) {
this.stream = await getMicrophoneStream();
this.chunks = [];
const mimeType = AudioRecorder.getBestMimeType();
console.log('录音格式:', mimeType);
this.recorder = new MediaRecorder(this.stream, {
mimeType,
audioBitsPerSecond: options.bitrate || 128000,
});
// 每隔 timeslice 毫秒收集一次数据(实时处理场景用)
// 不传 timeslice 则录完后一次性收集
this.recorder.addEventListener('dataavailable', (e) => {
if (e.data.size > 0) {
this.chunks.push(e.data);
}
});
this.recorder.addEventListener('stop', () => {
this._onRecordingComplete();
});
this.recorder.start(options.timeslice); // timeslice: 可选,单位 ms
this.startTime = Date.now();
console.log('开始录音...');
}
pause() {
if (this.recorder?.state === 'recording') {
this.recorder.pause();
}
}
resume() {
if (this.recorder?.state === 'paused') {
this.recorder.resume();
}
}
stop() {
if (this.recorder?.state !== 'inactive') {
this.recorder.stop();
this.stream.getTracks().forEach(t => t.stop());
}
}
getDuration() {
return (Date.now() - this.startTime) / 1000;
}
_onRecordingComplete() {
const mimeType = this.recorder.mimeType;
const blob = new Blob(this.chunks, { type: mimeType });
const url = URL.createObjectURL(blob);
const duration = this.getDuration();
console.log(`录音完成: s, s`);
// 触发下载
this._triggerDownload(blob, mimeType);
// 也可以直接播放
const audio = document.getElementById('playback');
if (audio) audio.src = url;
return { blob, url, duration };
}
_triggerDownload(blob, mimeType) {
const ext = mimeType.includes('webm') ? 'webm'
: mimeType.includes('ogg') ? 'ogg'
: 'mp4';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `recording-.`;
a.click();
URL.revokeObjectURL(a.href);
}
}实时录音波形预览
录音时实时显示波形,提升用户体验:
class RecordingVisualizer {
constructor(stream, canvas) {
this.audioCtx = new AudioContext();
this.source = this.audioCtx.createMediaStreamSource(stream);
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 1024;
this.source.connect(this.analyser);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.data = new Uint8Array(this.analyser.frequencyBinCount);
this.animId = null;
// 音量计(VU Meter)
this.vuData = new Float32Array(this.analyser.fftSize);
}
start() {
const draw = () => {
this.animId = requestAnimationFrame(draw);
this._drawVUMeter();
};
draw();
}
stop() {
cancelAnimationFrame(this.animId);
this.audioCtx.close();
}
_drawVUMeter() {
this.analyser.getByteTimeDomainData(this.data);
this.analyser.getFloatTimeDomainData(this.vuData);
const { ctx, canvas } = this;
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
// 计算 RMS 音量
let rms = 0;
for (let i = 0; i < this.vuData.length; i++) {
rms += this.vuData[i] * this.vuData[i];
}
rms = Math.sqrt(rms / this.vuData.length);
const db = Math.max(-60, 20 * Math.log10(rms));
// 绘制 VU 表
const vuWidth = W * 0.15;
const vuHeight = H * 0.8;
const vuY = H * 0.1;
const fillH = ((db + 60) / 60) * vuHeight;
// 背景
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(W - vuWidth - 10, vuY, vuWidth, vuHeight);
// 音量条(绿→黄→红)
const gradient = ctx.createLinearGradient(0, vuY + vuHeight, 0, vuY);
gradient.addColorStop(0, '#00e676');
gradient.addColorStop(0.7, '#ffea00');
gradient.addColorStop(0.9, '#ff1744');
gradient.addColorStop(1.0, '#ff1744');
ctx.fillStyle = gradient;
ctx.fillRect(
W - vuWidth - 10,
vuY + vuHeight - fillH,
vuWidth,
fillH
);
// 波形
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
const sliceW = (W - vuWidth - 20) / this.data.length;
for (let i = 0; i < this.data.length; i++) {
const v = this.data[i] / 128.0;
const x = i * sliceW;
const y = (v / 2) * H;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// 录音指示(红点闪烁)
const blink = Math.floor(Date.now() / 500) % 2 === 0;
if (blink) {
ctx.fillStyle = '#ff1744';
ctx.beginPath();
ctx.arc(15, 15, 6, 0, Math.PI * 2);
ctx.fill();
}
}
}四、WebRTC 音频:点对点实时通话
WebRTC(Web Real-Time Communication)是浏览器原生的点对点实时通信技术,音频部分基于 RTP/SRTP 协议传输,默认使用 Opus 编解码器。
WebRTC 建立连接的核心流程
完整的 WebRTC 音频通话实现
class WebRTCAudioCall {
constructor(signalingServer) {
this.pc = null; // RTCPeerConnection
this.localStream = null;
this.remoteAudio = document.getElementById('remoteAudio');
this.ws = new WebSocket(signalingServer);
// ICE 服务器配置(STUN 用于 NAT 穿透,TURN 用于中继)
this.iceConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// TURN 服务器(付费,用于严格 NAT 环境)
// {
// urls: 'turn:your-turn-server.com:3478',
// username: 'user',
// credential: 'password',
// },
],
};
this._setupSignaling();
}
// ── 信令处理 ─────────────────────────────────────────
_setupSignaling() {
this.ws.addEventListener('message', async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'offer':
await this._handleOffer(msg.sdp);
break;
case 'answer':
await this._handleAnswer(msg.sdp);
break;
case 'ice-candidate':
await this._handleICECandidate(msg.candidate);
break;
case 'hang-up':
this.hangUp();
break;
}
});
}
_send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
// ── 发起通话 ─────────────────────────────────────────
async call() {
this.localStream = await getMicrophoneStream({
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
this._createPeerConnection();
// 添加本地音频轨道
this.localStream.getAudioTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
// 创建并发送 Offer
const offer = await this.pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: false,
});
await this.pc.setLocalDescription(offer);
this._send({ type: 'offer', sdp: offer });
}
// ── 接受通话 ─────────────────────────────────────────
async _handleOffer(sdp) {
this.localStream = await getMicrophoneStream();
this._createPeerConnection();
this.localStream.getAudioTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this._send({ type: 'answer', sdp: answer });
}
async _handleAnswer(sdp) {
await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
}
async _handleICECandidate(candidate) {
if (candidate && this.pc) {
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
}
// ── 创建 PeerConnection ───────────────────────────────
_createPeerConnection() {
this.pc = new RTCPeerConnection(this.iceConfig);
// ICE 候选收集
this.pc.addEventListener('icecandidate', (e) => {
if (e.candidate) {
this._send({ type: 'ice-candidate', candidate: e.candidate });
}
});
// 连接状态变化
this.pc.addEventListener('connectionstatechange', () => {
console.log('连接状态:', this.pc.connectionState);
switch (this.pc.connectionState) {
case 'connected':
console.log('✅ P2P 连接已建立');
this._onConnected();
break;
case 'disconnected':
case 'failed':
console.warn('连接断开,尝试重连...');
this._onDisconnected();
break;
}
});
// ICE 连接状态
this.pc.addEventListener('iceconnectionstatechange', () => {
console.log('ICE 状态:', this.pc.iceConnectionState);
});
// 接收远端音频轨道
this.pc.addEventListener('track', (e) => {
console.log('收到远端轨道:', e.track.kind);
if (e.track.kind === 'audio') {
this.remoteAudio.srcObject = e.streams[0];
this.remoteAudio.play();
}
});
}
// ── 音频质量控制 ─────────────────────────────────────
async _onConnected() {
// 获取发送端 RTCRtpSender,调整编解码器参数
const sender = this.pc.getSenders()
.find(s => s.track?.kind === 'audio');
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings.length > 0) {
// 设置最大码率(bps)
params.encodings[0].maxBitrate = 128000; // 128kbps
await sender.setParameters(params);
}
}
}
// ── 静音控制 ─────────────────────────────────────────
mute(muted) {
this.localStream?.getAudioTracks().forEach(t => {
t.enabled = !muted;
});
}
// ── 挂断 ─────────────────────────────────────────────
hangUp() {
this._send({ type: 'hang-up' });
this.localStream?.getTracks().forEach(t => t.stop());
this.pc?.close();
this.pc = null;
this.localStream = null;
if (this.remoteAudio) {
this.remoteAudio.srcObject = null;
}
}
_onDisconnected() {
// 可以在这里实现自动重连逻辑
this.hangUp();
}
}五、WebRTC 音频统计与质量监控
WebRTC 提供了丰富的统计接口,用于监控通话质量:
class CallQualityMonitor {
constructor(peerConnection) {
this.pc = peerConnection;
this.interval = null;
this.prevStats = {};
}
start(onReport) {
this.interval = setInterval(async () => {
const report = await this._collectStats();
onReport(report);
}, 2000); // 每 2 秒收集一次
}
stop() {
clearInterval(this.interval);
}
async _collectStats() {
const stats = await this.pc.getStats();
const report = {
rtt: null, // 往返延迟(ms)
packetLoss: null, // 丢包率(%)
jitter: null, // 抖动(ms)
bitrate: null, // 实时码率(kbps)
audioLevel: null, // 音频电平
};
stats.forEach(stat => {
// 入站 RTP 流(接收端统计)
if (stat.type === 'inbound-rtp' && stat.kind === 'audio') {
report.jitter = (stat.jitter * 1000).toFixed(1); // 转 ms
report.audioLevel = stat.audioLevel;
// 计算丢包率
const prev = this.prevStats[stat.id];
if (prev) {
const lostDelta = stat.packetsLost - prev.packetsLost;
const receivedDelta = stat.packetsReceived - prev.packetsReceived;
const total = lostDelta + receivedDelta;
report.packetLoss = total > 0
? ((lostDelta / total) * 100).toFixed(1)
: '0.0';
// 计算码率
const bytesDelta = stat.bytesReceived - prev.bytesReceived;
const timeDelta = (stat.timestamp - prev.timestamp) / 1000;
report.bitrate = ((bytesDelta * 8) / timeDelta / 1000).toFixed(1);
}
this.prevStats[stat.id] = stat;
}
// 候选对统计(RTT)
if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
report.rtt = stat.currentRoundTripTime
? (stat.currentRoundTripTime * 1000).toFixed(0)
: null;
}
});
return report;
}
}
// 使用
const monitor = new CallQualityMonitor(webrtcCall.pc);
monitor.start((report) => {
console.log(`RTT: ms | 丢包: % | 抖动: ms | 码率: kbps`);
// 根据质量指标调整 UI
if (parseFloat(report.packetLoss) > 5) {
showWarning('网络质量较差,通话可能受影响');
}
});六、噪声消除与音频增强
浏览器内置的 noiseSuppression 和 echoCancellation 已经能处理大多数场景,但对于更高要求的场景,可以用 AudioWorklet 实现自定义的噪声处理:
// noise-gate-processor.js
// 噪声门:低于阈值的信号静音,高于阈值的信号正常通过
class NoiseGateProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: 'threshold',
defaultValue: 0.02, // 阈值(线性幅度)
minValue: 0,
maxValue: 1,
automationRate: 'k-rate',
},
{
name: 'attack',
defaultValue: 0.003, // 开门时间(秒)
minValue: 0.001,
maxValue: 0.5,
automationRate: 'k-rate',
},
{
name: 'release',
defaultValue: 0.1, // 关门时间(秒)
minValue: 0.001,
maxValue: 2.0,
automationRate: 'k-rate',
},
];
}
constructor() {
super();
this.gateGain = 0; // 当前门控增益(0=关,1=开)
this.isOpen = false;
}
process(inputs, outputs, parameters) {
const input = inputs[0][0];
const output = outputs[0][0];
if (!input || !output) return true;
const threshold = parameters.threshold[0];
const attack = parameters.attack[0];
const release = parameters.release[0];
// attack/release 转换为每采样的增益变化量
const attackCoeff = 1 - Math.exp(-1 / (sampleRate * attack));
const releaseCoeff = 1 - Math.exp(-1 / (sampleRate * release));
for (let i = 0; i < input.length; i++) {
const level = Math.abs(input[i]);
// 判断是否超过阈值
if (level > threshold) {
// 开门:增益向 1 靠近
this.gateGain += attackCoeff * (1 - this.gateGain);
} else {
// 关门:增益向 0 靠近
this.gateGain += releaseCoeff * (0 - this.gateGain);
}
// 应用门控增益
output[i] = input[i] * this.gateGain;
}
return true;
}
}
registerProcessor('noise-gate-processor', NoiseGateProcessor);在主线程中使用噪声门:
async function setupNoiseGate(stream) {
const audioCtx = new AudioContext({ sampleRate: 48000 });
// 加载 Worklet
await audioCtx.audioWorklet.addModule('noise-gate-processor.js');
const source = audioCtx.createMediaStreamSource(stream);
const noiseGate = new AudioWorkletNode(audioCtx, 'noise-gate-processor');
// 调整参数
noiseGate.parameters.get('threshold').value = 0.015; // 阈值
noiseGate.parameters.get('attack').value = 0.002; // 2ms 开门
noiseGate.parameters.get('release').value = 0.15; // 150ms 关门
// 输出为新的 MediaStream(用于 WebRTC 发送)
const dest = audioCtx.createMediaStreamDestination();
source.connect(noiseGate);
noiseGate.connect(dest);
return dest.stream; // 经过噪声门处理的干净音频流
}七、多人音频房间:SFU 架构
两人通话用 P2P 即可,但多人场景(3 人以上)P2P 的连接数会爆炸性增长(N 人需要 N×(N-1)/2 条连接)。生产环境的多人通话通常采用 SFU(Selective Forwarding Unit) 架构:
P2P 架构(4人): SFU 架构(4人):
A ←→ B A ←→ SFU ←→ B
A ←→ C vs C ←→ SFU ←→ D
A ←→ D (每人只需 1 条连接)
B ←→ C
B ←→ D
C ←→ D
共 6 条连接 共 4 条连接
SFU 服务器接收每个用户的音频流,然后选择性地转发给其他用户,不做混音(保持低延迟)。常用的开源 SFU 方案有 mediasoup、Janus、LiveKit。
客户端接入 SFU 的核心逻辑与标准 WebRTC 基本一致,差异在于信令协议:
class SFUAudioRoom {
constructor(roomId, userId) {
this.roomId = roomId;
this.userId = userId;
this.producers = new Map(); // 本地发送的轨道
this.consumers = new Map(); // 远端接收的轨道
this.remoteAudios = new Map(); // 远端音频元素
}
async join(signalingUrl) {
this.ws = new WebSocket(signalingUrl);
return new Promise((resolve) => {
this.ws.addEventListener('open', async () => {
// 1. 加入房间,获取 SFU 的 RTP 能力
const { routerRtpCapabilities } = await this._request('join', {
roomId: this.roomId,
userId: this.userId,
});
// 2. 加载设备能力(mediasoup-client)
// 实际项目需要引入 mediasoup-client 库
// this.device = new mediasoupClient.Device();
// await this.device.load({ routerRtpCapabilities });
resolve();
});
this.ws.addEventListener('message', (e) => {
this._handleMessage(JSON.parse(e.data));
});
});
}
async publish(stream) {
// 创建发送 Transport
const transportInfo = await this._request('createTransport', {
direction: 'send',
});
// 建立发送连接并发布音频轨道
const track = stream.getAudioTracks()[0];
console.log(`发布音频轨道: `);
// 通知其他用户有新的音频流
await this._request('produce', {
kind: 'audio',
rtpParameters: { /* 编解码器参数 */ },
});
}
async subscribe(remoteUserId) {
// 订阅指定用户的音频流
const { rtpParameters } = await this._request('consume', {
producerUserId: remoteUserId,
kind: 'audio',
});
// 创建接收 Transport 并播放
const audio = document.createElement('audio');
audio.autoplay = true;
this.remoteAudios.set(remoteUserId, audio);
document.body.appendChild(audio);
console.log(`订阅用户 [] 的音频`);
}
_handleMessage(msg) {
switch (msg.type) {
case 'user-joined':
console.log(`用户 [] 加入房间`);
this.subscribe(msg.userId);
break;
case 'user-left':
console.log(`用户 [] 离开房间`);
this._removeRemoteAudio(msg.userId);
break;
}
}
_removeRemoteAudio(userId) {
const audio = this.remoteAudios.get(userId);
if (audio) {
audio.srcObject = null;
audio.remove();
this.remoteAudios.delete(userId);
}
}
_request(type, data) {
return new Promise((resolve) => {
const id = Date.now();
this.ws.send(JSON.stringify({ id, type, ...data }));
const handler = (e) => {
const msg = JSON.parse(e.data);
if (msg.id === id) {
this.ws.removeEventListener('message', handler);
resolve(msg.data);
}
};
this.ws.addEventListener('message', handler);
});
}
async leave() {
await this._request('leave', { roomId: this.roomId });
this.remoteAudios.forEach(audio => {
audio.srcObject = null;
audio.remove();
});
this.ws.close();
}
}八、实战:完整的浏览器录音应用
把本章所有内容整合成一个功能完整的录音应用,包含权限申请、实时波形、噪声门、录音下载:
class VoiceRecorderApp {
constructor(container) {
this.container = container;
this.recorder = new AudioRecorder();
this.visualizer = null;
this.noiseGate = null;
this.state = 'idle'; // idle | recording | paused
this._buildUI();
}
_buildUI() {
this.container.innerHTML = `
<div class="recorder">
<canvas id="recCanvas" width="600" height="120"></canvas>
<div class="recorder-controls">
<button id="recBtn" class="rec-btn">● 开始录音</button>
<button id="pauseBtn" disabled>⏸ 暂停</button>
<button id="stopBtn" disabled>⏹ 停止</button>
</div>
<div class="recorder-info">
<span id="recTime">00:00</span>
<span id="recStatus">就绪</span>
<span id="recFormat"></span>
</div>
<audio id="playback" controls style="display:none; width:100%"></audio>
</div>
`;
document.getElementById('recBtn').addEventListener('click', () => this._toggleRecord());
document.getElementById('pauseBtn').addEventListener('click', () => this._togglePause());
document.getElementById('stopBtn').addEventListener('click', () => this._stop());
// 录音计时器
this._timerInterval = null;
this._elapsedSeconds = 0;
}
async _toggleRecord() {
if (this.state !== 'idle') return;
try {
// 1. 获取麦克风
const rawStream = await getMicrophoneStream({
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
// 2. 应用噪声门
const cleanStream = await setupNoiseGate(rawStream);
// 3. 启动可视化
const canvas = document.getElementById('recCanvas');
this.visualizer = new RecordingVisualizer(rawStream, canvas);
this.visualizer.start();
// 4. 开始录音
await this.recorder.start({ bitrate: 128000 });
// 5. 更新状态
this.state = 'recording';
this._updateUI();
this._startTimer();
// 显示录音格式
document.getElementById('recFormat').textContent =
AudioRecorder.getBestMimeType();
} catch (err) {
document.getElementById('recStatus').textContent = err.message;
console.error('录音启动失败:', err);
}
}
_togglePause() {
if (this.state === 'recording') {
this.recorder.pause();
this.visualizer?.stop();
this.state = 'paused';
clearInterval(this._timerInterval);
} else if (this.state === 'paused') {
this.recorder.resume();
this.visualizer?.start();
this.state = 'recording';
this._startTimer();
}
this._updateUI();
}
async _stop() {
if (this.state === 'idle') return;
this.recorder.stop();
this.visualizer?.stop();
clearInterval(this._timerInterval);
this.state = 'idle';
this._updateUI();
// 显示回放控件
const playback = document.getElementById('playback');
playback.style.display = 'block';
document.getElementById('recStatus').textContent = '录音完成,已自动下载';
}
_startTimer() {
this._timerInterval = setInterval(() => {
this._elapsedSeconds++;
const m = String(Math.floor(this._elapsedSeconds / 60)).padStart(2, '0');
const s = String(this._elapsedSeconds % 60).padStart(2, '0');
document.getElementById('recTime').textContent = `:`;
}, 1000);
}
_updateUI() {
const recBtn = document.getElementById('recBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const status = document.getElementById('recStatus');
const stateMap = {
idle: { recText: '● 开始录音', pauseText: '⏸ 暂停', recDisabled: false, pauseDisabled: true, stopDisabled: true, statusText: '就绪' },
recording: { recText: '● 录音中…', pauseText: '⏸ 暂停', recDisabled: true, pauseDisabled: false, stopDisabled: false, statusText: '录音中' },
paused: { recText: '● 开始录音', pauseText: '▶ 继续', recDisabled: true, pauseDisabled: false, stopDisabled: false, statusText: '已暂停' },
};
const ui = stateMap[this.state];
recBtn.textContent = ui.recText;
pauseBtn.textContent = ui.pauseText;
recBtn.disabled = ui.recDisabled;
pauseBtn.disabled = ui.pauseDisabled;
stopBtn.disabled = ui.stopDisabled;
status.textContent = ui.statusText;
}
}
// 初始化应用
const app = new VoiceRecorderApp(document.getElementById('app'));九、本章知识图谱
小结
实时音频是 Web 音频技术栈中最复杂、也最有价值的部分。getUserMedia 打开了麦克风的大门,MediaRecorder 让录音变得简单,Web Audio + MediaStream 的组合实现了实时信号处理,而 WebRTC 则把音频真正推向了网络——点对点、低延迟、加密传输。
理解这套技术栈的关键在于数据流向:麦克风 → MediaStream → Web Audio 处理链 → MediaStreamDestination → WebRTC 发送 → 网络 → WebRTC 接收 → <audio> 播放。每一个环节都可以插入处理逻辑,这正是 Web 实时音频强大灵活性的来源。
下一章我们将聚焦移动端兼容与性能优化——iOS Safari 的种种限制、Android 的碎片化问题、低端设备的性能瓶颈,以及如何在保证功能完整的前提下,让你的音频应用在所有设备上都流畅运行。