Web 音视频(四)在浏览器中处理音频

news/2025/2/9 4:40:16/

前言

为什么单独介绍音频处理?

  1. 网络上缺乏音频处理的资料,绝大多数示例都是针对视频而略过音频,很多人在网上寻找音频处理的示例
  2. 对前端开发者来说,音频处理相对视频略微复杂一些

所以,本文专门针对音频数据,汇总讲解采集-处理-编码-封装全过程,帮助初学者入门。

audio-data-flow
上图是在 Web 中从采集音频到封装文件的大致流程。

你可以跳过原理介绍,直接查看文末 WebAV 编码封装音频示例

采集

数字化声波

声音的本质是波,对一段连续声波进行采样,每一个点用一个浮点数来表示,声音就被数字化成了一个浮点数组;js 中常使用 Float32Array 来记录数字化后的浮点数组。
注:任意类型的数字数组都可描述声波,后文使用 Float32Array 指代 PCM 对应的数据

如果对音频、波、数字化没什么概念,强烈建议体验 Waveforms(opens new window)

数字化(Pulse Code Modulation, PCM)一段声音后(Float32Array),还需要几个必要属性来描述这段数据

  • SampleRate(采样率):采样声波的频率,48KHz 就是每秒采样 48000 个数字
  • ChannelCount(声道数):声音来源数量,比如两个声波(双声道)采样后会得到两个 Float32Array,通常会将它们前后拼接成一个 Float32Array,前一半为左声道声波采样数据,后一半为右声道数据

*为什么没有时长属性?*
因为时长可以通过 ChannelCount、SampleRate 算出来
duration = Float32Array.length / ChannelCount / SampleRate

假设一段单声道音频数据(Float32Array)长度为 96000,SampleRate 为 48KHz
那么这段音频的持续时长为: 96000 / 1 / 48000 = 2 秒

声音数据(Float32Array)来源

列举三个主要音频来源,转换到 Float32Array 的过程

  1. 本地或网络音频文件 -> ArrayBuffer -> AudioContext.decodeAudioData -> Float32Array
  2. Video or Audio Element -> MediaElement.captureStream() -> MediaStream -> MediaStreamTrack -> MediaStreamTrackProcessor -> AudioData -> Float32Array
  3. 麦克风、屏幕分享 -> MediaStream -> ...(如上)

转换过程不算复杂,只是需要阅读的 API 比较多;获得 Float32Array 后就可以进行下一步处理。

处理

图像处理的计算复杂度高很多,依赖硬件加速;对前端开发者来说是绘制到 canvas,使用对应的 API 去操作即可,反而对音频处理更陌生。

这里举几个常见例子,帮助大家熟悉音频处理逻辑;可以选择写代码编辑音频数据,也可以借助已有的 Web Audio API (如 重采样)。

Web Audio API

Web Audio API 提供了在 Web 上控制音频的一个非常有效通用的系统,允许开发者来自选音频源,对音频添加特效,使音频可视化,添加空间效果(如平移)等等。

先提一下 Web Audio API (opens new window),它包含非常多的 API 用于在 Web 中创建、处理音频;
本文会依赖其中少量 API,但不会过多介绍,有兴趣的同学可以阅读张鑫旭的文章 JS 交互增加声音(opens new window)

音量调节

中学物理学过,声波的振幅表示音量大小,乘以一个数可以改变振幅(音量);
所以 Float32Array 乘以 0~1 之间小数相当于降低音量,大于 1 相当于增大音量

javascript">for (let i = 0; i < float32Arr.length; i++) float32Arr[i] *= 0.5;

以上是原理,但人耳对音量大小的感知是对数而不是线性关系,实际音量调节要复杂很多,可阅读 PCM 音量控制(opens new window)

混流

因为声音的本质是波,所以多个声音混合即波的叠加,使用加法即可

float32Arr1 + float32Arr2 => outFloat32Arr

javascript">const len = Math.max(float32Arr1.length, float32Arr2.length);
const outFloat32Arr = new Float32Array(len);
for (let i = 0; i < len; i++)outFloat32Arr[i] = (float32Arr1[i] ?? 0) + (float32Arr2[i] ?? 0);

声音淡入/淡出

最常见的场景,点击按钮暂停音乐时,声音大小是快速降低为 0,而不是瞬间消失。
多年前很多音乐播放器是没有做声音淡出的,现在已经体验不到那种声音瞬间消失的难受感觉了。

假设需要截断一个音频,为了前半段的音频结尾听起不那么难受,需要将结尾的 0.5s 音量降低至 0;音频采样率为 48KHz。

javascript">// 生成 1s 随机 PCM 数据
const pcmF32Arr = new Float32Array(Array(48000).fill(0).map(() => Math.random() * 2 - 1)
);
// 开始位置距离结尾 0.5s(采样率 / 2)
const start = pcmF32Arr.length - 1 - 48000 / 2;
for (let i = 0; i < 48000 / 2; i += 1)// 逐渐降低音量pcmF32Arr[start + i] *= 1 - i / 48000 / 2;

重采样

当输入的音频采样率跟输出不一致,或需要混流两个采样率不同的音频时,就需要对音频进行重采样,改变音频的采样率。

重采样的原理是对 Float32Array 进行抽取或插值;
比如,48KHz 采样率的音频 1s 有 48000 个点(数字),采样率降低至 44.1KHz 则 1s 的音频需要丢掉其中 48000 - 44100 个点。

在 Web 中 AudioContext、OfflineAudioContext API 已经提供重采样能力,我们可以利用 API 来对音频进行重采样,无需自己实现相关算法。

使用 OfflineAudioContext 重采样音频数据:

javascript">/*** 音频 PCM 重采样* @param pcmData PCM* @param curRate 当前采样率* @param target { rate: 目标采样率, chanCount: 目标声道数 }* @returns PCM*/
export async function audioResample (pcmData: Float32Array[],curRate: number,target: {rate: numberchanCount: number}
): Promise<Float32Array[]> {const chanCnt = pcmData.lengthconst emptyPCM = Array(target.chanCount).fill(0).map(() => new Float32Array(0))if (chanCnt === 0) return emptyPCMconst len = Math.max(...pcmData.map(c => c.length))if (len === 0) return emptyPCMconst ctx = new OfflineAudioContext(target.chanCount,(len * target.rate) / curRate,target.rate)const abSource = ctx.createBufferSource()const ab = ctx.createBuffer(chanCnt, len, curRate)pcmData.forEach((d, idx) => ab.copyToChannel(d, idx))abSource.buffer = ababSource.connect(ctx.destination)abSource.start()return extractPCM4AudioBuffer(await ctx.startRendering())
}

WebAV 中的 audioResample 函数源码

WebWorker 环境中不存在 OfflineAudioContext,可采用 js 实现的音频重采样库来实现,如 wave-resampler

javascript">import { resample } from 'wave-resampler';
// The Worker scope does not have access to OfflineAudioContext
if (globalThis.OfflineAudioContext == null) {return pcmData.map((p) =>new Float32Array(resample(p, curRate, target.rate, { method: 'sinc', LPF: false })));
}

编码音频

因为 AudioEncoder (opens new window)只能编码 AudioData (opens new window)对象,所以需要先将 Float32Array 转换成 AudioData 对象。

javascript">new AudioData({// 当前音频片段的时间偏移timestamp: 0,// 双声道numberOfChannels: 2,// 帧数,就是多少个数据点,因为双声道,前一半左声道后一半右声道,所以帧数需要除以 2numberOfFrames: pcmF32Arr.length / 2,// 48KHz 采样率sampleRate: 48000,// 通常 32位 左右声道并排的意思,更多 format 看 AudioData 文档format: 'f32-planar',data: pcmF32Arr,
});

创建并初始化音频编码器

javascript">const encoder = new AudioEncoder({output: (chunk) => {// 编码(压缩)输出的 EncodedAudioChunk},error: console.error,
});encoder.configure({// AAC 编码格式codec: 'mp4a.40.2',sampleRate: 48000,numberOfChannels: 2,
});// 编码原始数据对应的 AudioData
encoder.encode(audioData);

TIP

创建编码器之前,记得先使用 AudioEncoder.isConfigSupported 检测兼容性

封装

继续使用 mp4box.js 来演示封装 EncodedAudioChunk

主要代码

javascript">const file = mp4box.createFile();
const audioTrackId = file.addTrack({timescale: 1e6,samplerate: 48000,channel_count: 2,hdlr: 'soun',// mp4a是封装格式,对应的 AAC 是编码格式type: 'mp4a',// meta 来原于 AudioEncoder output 的参数// 不传递这个字段,windows media player 播放视频时没有声音description: createESDSBox(meta.decoderConfig.description),
});/*** EncodedAudioChunk | EncodedVideoChunk 转换为 MP4 addSample 需要的参数*/
function chunk2MP4SampleOpts(chunk: EncodedAudioChunk | EncodedVideoChunk
): SampleOpts & {data: ArrayBuffer,
} {const buf = new ArrayBuffer(chunk.byteLength);chunk.copyTo(buf);const dts = chunk.timestamp;return {duration: chunk.duration ?? 0,dts,cts: dts,is_sync: chunk.type === 'key',data: buf,};
}// AudioEncoder output chunk
const audioSample = chunk2MP4SampleOpts(chunk);
file.addSample(audioTrackId, audioSample.data, audioSample);

限于篇幅,上述代码依赖的 createESDSBox 的源码在这里

如果你需要自己写代码实现音频封装逻辑,需要注意:

  1. mp4box.js 创建 ESDS Box 还有 Bug 未修复,查看详情
  2. 音频轨道与视频轨道创建完成(addTrack)之后才能添加数据(addSample),而创建轨道都是需要等待编码器(VideoEncoder、AudioEncoder)的 meta 数据,请查看同步创建音视频轨道代码
  3. 封装的音频数据时间(Sample.cts)似乎不能用来控制音频偏移,如果某一段时间没有声音,你仍然需要填充数据,比如 10s 没有声音的 PCM 数据
    new Float32Array(Array(10 * 48000).fill(0))

WebAV 编码封装音频示例

如果从零开始实现编码封装音频,除了参考以上的原理介绍,还需要阅读大量 API 和细节处理。

你可以略过细节,使用 @webav/av-cliper 提供的工具函数 recodemux 、 file2stream 来快速编码封装音频,创建视频文件。

以下是从麦克风获取音频数据并编码封装的核心代码。

javascript">import { recodemux, file2stream } from '@webav/av-cliper';const muxer = recodemux({// 目前视频是必填的,参考上一篇文章获取视频帧video: {width: 1280,height: 720,expectFPS: 30,},audio: {codec: 'aac',sampleRate: 48000,channelCount: 2,},
});// 500ms 写一次文件
// upload or write stream
const { stream, stop: stopOutput } = file2stream(muxer.mp4file, 500);const mediaStream = await navigator.mediaDevices.getUserMedia({video: true,audio: true,
});
const audioTrack = mediaStream.getAudioTracks()[0];
const reader = new MediaStreamTrackProcessor({track: audioTrack,
}).readable.getReader();
async function readAudioData() {while (true) {const { value, done } = await reader.read();if (done) {stopOutput();}await muxer.encodeAudio(value);}
}
readAudioData.catch(console.error);

关于优联前端

        武汉优联前端科技有限公司由一批从事前端10余年的专业人才创办,是一家致力于H5前端技术研究的科技创新型公司,为合作伙伴提供专业高效的前端解决方案,合作伙伴遍布中国及东南亚地区,行业涵盖广告,教育, 医疗,餐饮等。有效的解决了合作伙伴的前端技术难题,节约了成本,实现合作共赢。承接开发Web前端,微信小程序、小游戏,2D/3D游戏,动画交互与UI广告设计等各种技术研发。

 


http://www.ppmy.cn/news/1570504.html

相关文章

代理模式的作用

一、代理模式 代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问&#xff0c;这样就可以在不修改原目标对象的前提下&#xff0c;提供额外的功能操作&#xff0c;扩展目标对象的功能。 代理模式的主要作用是扩展目标对…

ubuntu20.04+RTX4060Ti大模型环境安装

装显卡驱动 这里是重点&#xff0c;因为我是跑深度学习的&#xff0c;要用CUDA&#xff0c;所以必须得装官方的驱动&#xff0c;Ubuntu的附件驱动可能不太行. 进入官网https://www.nvidia.cn/geforce/drivers/&#xff0c;选择类型&#xff0c;最新版本下载。 挨个运行&#…

洛谷网站: P3029 [USACO11NOV] Cow Lineup S 题解

题目传送门&#xff1a; P3029 [USACO11NOV] Cow Lineup S - 洛谷 | 计算机科学教育新生态 (luogu.com.cn) 前言&#xff1a; 这道题的核心问题是在一条直线上分布着不同品种的牛&#xff0c;要找出一个连续区间&#xff0c;使得这个区间内包含所有不同品种的牛&#xff0c;…

如何在Vscode中接入Deepseek

一、获取Deepseek APIKEY 首先&#xff0c;登录Deepseek官网的开放平台&#xff1a;DeepSeek 选择API开放平台&#xff0c;然后登录Deepseek后台。 点击左侧菜单栏“API keys”&#xff0c;并创建API key。 需要注意的是&#xff0c;生成API key复制保存到本地&#xff0c;丢失…

HarmonyOS 5.0应用开发——ContentSlot的使用

【高心星出品】 文章目录 ContentSlot的使用使用方法案例运行结果 完整代码 ContentSlot的使用 用于渲染并管理Native层使用C-API创建的组件同时也支持ArkTS创建的NodeContent对象。 支持混合模式开发&#xff0c;当容器是ArkTS组件&#xff0c;子组件在Native侧创建时&#…

zephyr devicetree

Syntax and structure — Zephyr Project Documentation Input files There are four types of devicetree input files: sources (.dts) includes (.dtsi) overlays (.overlay) bindings (.yaml) The devicetree files inside the zephyr directory look like this: …

PostgreSQL中级认证价值

PostgreSQL&#xff0c;作为一款开源的关系型数据库管理系统&#xff0c;以其强大的功能、高度的可扩展性和稳定性&#xff0c;赢得了广泛的认可。对于非科班出身、IT知识储备有限的你&#xff0c;选择PostgreSQL中级认证专家的学习路径&#xff0c;不仅是一次技能的提升&#…

【前端基础】深度理解JavaScript中的异步机制

深入理解JavaScript中的异步机制 前言一、JavaScript的单线程模型二、异步队列&#xff08;Callback Queue&#xff09;1.事件循环&#xff08;Event Loop&#xff09;2.微任务队列与宏任务队列 三、回调函数&#xff08;Callback&#xff09;1. 回调函数的基本用法2. 回调地狱…