Stream 与音频播放技术
Stream 与音频播放技术
使用异步请求加载流式数据,实现边加载边播放的功能。
说明
想要流畅阅读本文,需要读者具备以下知识储备:
- 熟悉 Stream API
- 熟悉 Fetch API
- 熟悉对象继承概念
- 工程化前端项目经验
- 了解 HTTP 协议及 HTTP 服务器
- 了解 ECMAScript 2024
- 了解 Media Source Extensions API
参考链接
- Media Source Extensions API - MDN
- Web Audio API - MDN
- 浅谈 AudioContext 音频上下文 - CSDN
- AudioContext 入门 - 稀土掘金
- 流式音频播放 - 稀土掘金
前言
工作中遇到了类似功能逻辑,初次看到时,就觉得其代码不够优雅,主要体现在没有充分运用 Stream API 流背压技术。
除此之外,我对 Web 媒体技术 本身也具有很高的学习热情。因为现代媒体技术已不同于以往使用 Flash 技术的时代,现在相关工作组已经其规范标准化。
国内由于各种原因并未采用相关标准实现在线直播或语音聊天,这让我感觉些许失望。最近两天花了点时间学习了相关概念,虽然仅限皮毛且仅限于音频技术,但已足以应付工作中的相关问题,更重要的是通过写作记录加深印象。
Web 媒体技术涉及诸多概念,本文主旨在于介绍以下场景:
当播放源不能通过地址简单获取数据流时的情况。例如,需要在请求头中加上 Authorization 请求头,且不知道音频长度时,实现一边播放一边拉取音频数据。
此类情况适用于需要权限查看的音频或来自服务器的合成音频。对于一开始就已知音频长度、可以一次性将数据流拉取完再播放的情况,不适用于本文。
本文案例也可用于网络情况不佳但又需要及时播放的场景。同理,也适用于网络情况良好但带宽不够时,实现加载高负载的播放内容,减轻客户端加载压力。
[!NOTE] 对于本文的初版,仅为处理
dify的语音合成播放而写的。后来,经过详细了解后,发现初版文章内容理解有所偏差,且技术需求用不上初版文章中所涉及到的 AudioContext API。 由此,本文在2025年11月22日进行了调整:
- 增加
MSE API播放与案例- 调整
AudioContext案例- 增加外部资料引用
本文内容可访问案例预览运行效果
一、技术需求级别
本质上,使用到媒体播放一般有两类技术级别:
- 单纯用于播放音频
- 对音频进行增幅处理和分析
第一类用于 单纯音频播放,例如可能从某个静态文件地址,或者从通过某个接口返回的音频数据流播放音频。第二类则是 更高级的需求,例如:调整音频效果,或者为了分析音频音谱,而是否播放音频则可能是次要的。
下文,将介绍两类需求级别。
二、简单播放音频
对于简单播放需求的情况,也分两类播放源,第一种是已经存在一个静态地址,可以获得媒体源。对于此类情况,只需要使用 <audio> 即可满足需要。
HTML 标签播放
最简单的音频播放采用如下标签格式即可。
例如:
<audio controls> <source src="file.mp3" /></audio>异步请求音频数据
通过异步请求 MP3 则不同,此种情况用于请求音频流时,发起方需要携带一大堆数据的情况。通过异步请求返回的音频数据流作为音频源,进行播放。
[!WARNING] 采用异步加载的方法,不被 Firefox 所支持,原因在于 MSE 不支持纯 MP3,因为 MP3 不是分段媒体格式。 因此,在 Firefox 这样相对来说更加注重标准本身的浏览器上无法正常使用!但是能在基于 Chrome 内核的浏览器上使用。如果想要使用 MSE 播放,推荐将 MP3 格式封装在 MP4 容器中,或者将 MP3 文件格式化为 AAC 格式(
m4a.40.2)。
1. 基本流程
---
title: 异步音频播放概览
---
graph TB
Audio[new Audio]-- audioObj --> out
s[new MediaSource]-- mediaSourceObj -->URL[URL.createObjectURL]
URL-- urlObj --> out(("audioObj.src=urlObj"))
基本流程如上所示,需要着重关注的对象应该是 MediaSource,因为 MediaSource 才是音频数据的源头。
const mediaSource = new MediaSource();const audio = new Audio();audio.src = URL.createObjectURL(mediaSource);2. 等待 MediaSource
在为源头添加数据前,需要等待 MediaSource 准备完毕才行。通过 MediaSource 触发的 sourceopen 事件,便能知晓 MediaSource 已经准备完毕。
mediaSource.addEventListener("sourceopen", async () => { console.log( "mediaSourceManager 已准备好接收数据", mediaSource.readyState, ); if (sourceBuffer) return;
if (MediaSource.isTypeSupported("audio/mpeg")) { // 在这里为 MediaSource 声明缓冲区 } else { throw new Error( "浏览器不支持任何 MSE 音频格式。请使用 MP4 容器格式的音频文件(AAC 或 MP3 in MP4)。", ); }});此处引入了一个新的概念,称为缓冲区 (SourceBuffer)。一个 MediaSource 是可以添加多个缓冲区的,相当于可以存在数个数据流缓冲。
根本上,通过异步数据返回的流,是添加在缓冲区对象中的。通过缓冲对象与 MediaSource 建立传输连接,实现数据流到 audio 的播放。
接下来,通过 UI 交互触发流的拉取, 再到流写入 SourceBuffer , 最后实现数据流到audio的播放。
3. 创建缓冲区
// 音频流缓冲对象let sourceBuffer: SourceBuffer | undefined;mediaSource.addEventListener("sourceopen", async () => { console.log( "mediaSourceManager 已准备好接收数据", mediaSource.readyState, ); if (sourceBuffer) return;
if (MediaSource.isTypeSupported("audio/mpeg")) { sourceBuffer = mediaSource.addSourceBuffer("audio/mpeg"); // 在这里为 SourceBuffer 添加数据流 } else { throw new Error( "浏览器不支持任何 MSE 音频格式。请使用 MP4 容器格式的音频文件(AAC 或 MP3 in MP4)。", ); }});4. 拉取并填充数据
以下代码由 React 编写的 UI,具体语法此处不介绍。主要描述了加载按钮、播放操作和暂停播放。具体函数内容将在后文介绍,此处作为上下文补充内容。
<Button onClick={() => { loadMp3()}}> 加载</Button><Button disabled={!loaded} onClick={() => audio.play()}> 播放</Button><Button onClick={() => audio?.pause()}> 暂停</Button>主要加载逻辑部分:
/** * 通过 Promise 实现等待缓冲更新完成事件 */async function awatingSourceBuffer() { const { resolve, promise } = Promise.withResolvers<void>(); sourceBuffer?.addEventListener("updateend", () => resolve(), { once: true }); return promise;}
function loadMp3(url?: string) { if (loaded) { return; } fetch(url ?? "/assets/b.mp3").then(async (res) => { if (!res.body) { return; } const writerStream = new WritableStream({ start(controller) { console.log("开始接受媒体流数据"); }, async write(chunk, controller) { while (sourceBuffer?.updating) { // 等待更新状态稳定 console.log("sourceBuffer 正在更新,等待更新完成"); await awatingSourceBuffer(); }
const arrayBuffer = chunk.buffer; console.assert( !sourceBuffer?.updating, "sourceBuffer 处于异常更新状态", ); sourceBuffer?.appendBuffer(arrayBuffer); console.log("写入 sourceBuffer 完成,写入大小:", arrayBuffer.byteLength); }, async close() { while (sourceBuffer?.updating) { console.log("sourceBuffer 正在更新,等待更新完成"); await awatingSourceBuffer(); } mediaSource.endOfStream(); loaded = true; }, abort(reason) { mediaSource.endOfStream(); console.log("SourceBuffer WritableStream aborted:", reason); }, }); res.body.pipeTo(writerStream); });}小结
可以看到,通过 ReadableStream API 持续读取音频数据,并结合 WritableStream 对 sourceBuffer 持续添加字节数据,完成从拉取数据并最终消费到缓冲区。缓冲区对象本身会将其内部的数据发送到 MediaSource,并最终给 audio 消费,实现音频播放。
---
title: 通过 MSE API 消费异步数据流
---
graph TB
Audio[new Audio]-.- audioObj -.-> out
Audio==>s
s[new MediaSource]==>|mediaSourceObj|URL[URL.createObjectURL]
s-.->|on sourceopen trigger|support
URL==>|urlObj| out(("audioObj.src=urlObj"))
subgraph 异步处理子程序
support{"是否支持 MP3 格式"} -- true --> createBuffer["addSourceBuffer"]
createBuffer-.->|bufferObj|pushBuffer
createBuffer-->fetchStream["fetch('file.mp3')"]
fetchStream-->|data block|pushBuffer["bufferObj.appendBuffer"]
support -- false --> Error["throw Error()"]
end
pushBuffer stream@==>|media stream|s
stream@{ animate: true }
三、音频高阶处理
此部分内容属于更高级的内容,服务目标是对音频的源做处理和可视化分析。对于是否将音频源在本地消费掉是可选的。
流程概览
AudioContext 设计上兼容已有的 Web 媒体 API 。 通过 createMediaElementSource 将 MediaElement 转化为 AudioContext 数据源节点(SourceNode). 对于 MediaStream 则通过 createMediaStreamSource 创建数据源节点(SourceNode)。同时,AudioContext 自身也可以创建音频振荡器作为音频源。甚至, 你还可以通过直接创建缓冲数据区添加数据, 而不是通过已有的数据源.
---
title: AudioContext 数据流处理流程
---
graph LR
mediaElement([MediaElements])-.->createMediaElementSource
mediaStream([MediaStreams])-.->createMediaStreamSource
subgraph AudioContext
createMediaElementSource[createMediaElementSource]-->source
createMediaStreamSource[createMediaStreamSource]-->source
createOscillator-->source
createBufferSource-->source
source[Source nodes]==>effect[Effect Nodes]
effect-.->destination((Destination))
end
根据本文的前文所描述的,Audio 属于 HTMLAudioElement 类型,继承于 HTMLMediaElement。因此 Audio 就是 MediaElement 的子类。
将音频源(Source Node)节点连接下一个音频效果处理程序(Effect Node),这些节点算一个音频处理模块,每个处理模块可以连接下一个音频处理模块,形成一个处理链(路由/图)。
经过音频处理模块后,最终可以选择一个输出目的地,比如扬声器,但不是必须的。
以上,通过这些 API 处理过程中,可以对音频进行编程,时间和效果都可以进行非常精确的控制。
关键概念
1. AudioContext
AudioContext 本质上是为音频处理工作流提供一个程序空间(上下文),用于声明音频源、音频处理(增益)顺序以及音频输出目的地的过程。因此,在开始处理任何音频前,应该创建一个 AudioContext。
AudioContext 继承于 BaseAudioContext,所以很多 BaseAudioContext 的方法也能在 AudioContext 中使用,比如 decodeAudioData 方法,用于将原始的 ArrayBuffer 解码后返回音频数据(返回的也是 ArrayBuffer)。
2. AudioNode
此概念为 AudioContext 上下文中的处理节点。例如,AudioNode 节点可以是一个音频源节点,或者是一个音效处理节点,亦或是一个增幅节点,还可能是一个目的地节点。
--- title: AudioContext 工作上下文工作流 --- flowchart LR sourceNode([SourceNode])-->EffectNode--> destinationNode((destination))
3. 音频源节点
所谓 SourceNode,是 AudioNode 的一个子大类,本质上是 AudioNode,不同的是,它是音频处理流程中的开始节点。
包含以下节点类型:
- OscillatorNode 振荡器
- MediaElementAudioSourceNode 文档元素中的媒体源
- MediaStreamAudioSourceNode 流媒体 (WebRTC)
- AudioBufferSourceNode 自定义音频源
[!NOTE]
AudioBufferSourceNode通常由audioContext.decodeAudioData或audioContext.createBuffer方法产生的 AudioBuffer 结合使用。
参考资料:
4. 增幅/音效节点
音效节点(处理模块)用于接受并处理来自于音频源节点的数据,音效节点(处理模块)也可以接受其他的音效节点的处理输出作为音频源。
包含以下节点类型:
- BiquadFilterNode 滤波器
- ConvolverNode 混响
- DelayNode 延迟
- DynamicsCompressorNode 混合压缩
- GainNode 音量处理
- StereoPannerNode 立体声控制
- WaveShaperNode
- PeriodicWave 周期性塑性
5. 终点节点
该对象继承于 AudioNode,但着重于描述一个 AudioContext 音频处理的出口,即对音频进行最后处理。
一共存在两个类型:
默认情况下,AudioContext 实例有一个 destination 属性,就是该对象。
MediaStreamAudioDestinationNode
用于 WebRTC,用于将处理后的音效作为新的 MediaStream。
6. 可视化
参考资料:
7. 声道分离/合并
参考资料:
四、AudioContext 简单案例
[!TIP] 案例通过限制网络加载速度模拟一边播放一边加载的场景。 音频文件本身不大,但通过将网络速度限制到非常低方式,类比低速网络场景。
案例目标为实现一边加载一边播放,以保证用户端获得更好的加载体验。
流媒体服务器
采用 h3 作为服务器底层框架。主要源码结构如下:
.├── README.MD├── apps│ ├── a.mp3│ ├── b.mp3│ └── mp3.ts├── deno.json├── deno.lock└── main.tsmain.ts 为整个服务器入口文件,关键代码如下:
import { H3, serve } from "h3";import mp3Response from "./apps/mp3.ts";
const app = new H3();app.mount("/mp3", mp3Response);serve(app, { port: 3000 });其中 mp3.ts 是返回 MP3 音频的全部代码,如下所示。需要注意的是,MP3 文件在服务端是按照 Stream 流式返回的。
import { defineHandler, getRouterParam, H3 } from "h3";
const app = new H3();
app.get( "/:filePath", defineHandler(async (event) => { const filepath = getRouterParam(event, "filePath") as string; const path = [import.meta.dirname, filepath?.replace(/^\//, "")].join("/");
try { const file = await Deno.open(path, { read: true }); return new Response(file.readable, { headers: { "Content-Type": "audio/mpeg", }, }); } catch (error) { console.error(error); return Response.json({ message: "only mp3 file a.mp3 and b.mp3 is supported", }, { status: 404, }); } }),);
export default app;前置准备
1. 创建 AudioContext
如前文概念所述,AudioContext 是处理音频的关键对象,音频处理的所有过程均在 AudioContext 程序空间中进行。因此首先应创建 AudioContext 对象:
const audioContext = new AudioContext();2. 准备 MediaElement
出于简单性考虑,本文采用 MediaElement 的方式,作为 AudioContext 的音频源(SourceNode)。
const mediaSource = new MediaSource()const audio = new Audio();audio.src = URL.createObjectURL(mediaSource)3. 工作流定义
现在要指定 AudioContext 如何处理各个 AudioNode 的前后关系,代码如下:
const audioSouceNode = audioContext.createMediaElementSource(audio)audioSouceNode.connect(audioContext.destination)此定义表示从数据源节点直接通向了扬声器, 没有经过音效处理模块(EffectAudioNode)
小结
本逻辑部分并未涉及到音频增幅/音效处理节点,而是获得数据流后直接向扬声器输出。
---
title: 音频处理准备与流程定义
---
flowchart TB
mediaSource[new MediaSource]-->|mediaSource|urlObj[createObjectURL]
audio[new Audio]-->|audio|link[audio.src=urlObj]
urlObj-->|urlObj|link
link -->|"audio(MediaElement)"|createNode
subgraph AudioContext
createNode[createMediaElementSource]-->sourceNode
sourceNode-->destination((to destination))
end
数据源填充
1. 准备缓冲对象
现在 mediaSource 充当媒体源管理对象,用于管理多个音频源。因此要添加音频源,须通过管理对象指定。
[!NOTE] 在通过管理对象创建缓冲区之前,必须等待该管理器对象准备好 (通过
sourceopen事件),否则程序将会报错。
let sourceBuffer: SourceBuffer | undefined
mediaSource.addEventListener('sourceopen', async () => { console.log('mediaSource 已准备好接收数据', mediaSource.readyState); if (sourceBuffer) { return }
if (MediaSource.isTypeSupported('audio/mpeg')) { sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg') } else { throw new Error('浏览器不支持任何 MSE 音频格式。请使用 MP4 容器格式的音频文件(AAC 或 MP3 in MP4)。') }})这样,就通过 mediaSource 创建了一个音频接收对象,该对象可以通过 appendBuffer 方法添加(处理/消费)不断流入的音频数据流。
2. 音频拉取并写入缓冲
let loaded = false;
function loadMp3(url?: string) { if (loaded) return;
fetch(url ?? "mp3/b.mp3").then(async (res) => { if (!res.body) return;
const writerStream = new WritableStream({ start(controller) { console.log('开始接受媒体流数据'); }, async write(chunk, controller) { while (sourceBuffer?.updating) { // 等待更新状态稳定 console.log('sourceBuffer 正在更新,等待更新完成'); await awatingSourceBuffer(); }
const arrayBuffer = chunk.buffer; console.assert(!sourceBuffer?.updating, 'sourceBuffer 处于异常更新状态'); sourceBuffer?.appendBuffer(arrayBuffer); console.log('写入 sourceBuffer 完成,写入大小:', arrayBuffer.byteLength); }, async close() { console.log('音频数据已接收完毕'); while (sourceBuffer?.updating) { console.log('sourceBuffer 正在更新,等待更新完成'); await awatingSourceBuffer(); } console.log('结束数据流,将数据流写入 sourceBuffer 完成,开始 endOfStream'); mediaSource.endOfStream(); loaded = true; }, abort(reason) { console.log('SourceBuffer WritableStream aborted:', reason); } }); res.body.pipeTo(writerStream); });}拉取流部分, 采用了 ECMAScript Stream API, 以获得流的背压能力
五、客户端逻辑源码
TypeScript 部分
import mime from 'mime'
const audioContext = new AudioContext();export let mediaSource = new MediaSource()
export const audio = new Audio()audio.src = URL.createObjectURL(mediaSource)
const audioSouceNode = audioContext.createMediaElementSource(audio)audioSouceNode.connect(audioContext.destination)
export let sourceBuffer: SourceBuffer | undefinedmediaSource.addEventListener('sourceopen', async () => { console.log('mediaSource 已准备好接收数据', mediaSource.readyState); if (sourceBuffer) { return }
const mp3format = mime.getType('.mp3')!
if (MediaSource.isTypeSupported(mp3format)) { sourceBuffer = mediaSource.addSourceBuffer(mp3format) } else { throw new Error('浏览器不支持任何 MSE 音频格式。请使用 MP4 容器格式的音频文件(AAC 或 MP3 in MP4)。') }})
mediaSource.addEventListener('sourceended', () => { console.log('媒体流已结束')})
mediaSource.addEventListener('sourceclose', () => { console.log('媒体流已关闭')})
async function awatingSourceBuffer() { const { resolve, promise } = Promise.withResolvers<void>() sourceBuffer?.addEventListener('updateend', () => resolve(), { once: true }) return promise}let loaded = false
export function loadMp3(url?: string) { if (loaded) { return } fetch(url ?? '/assets/b.mp3',) .then(async res => { if (!res.body) { return }
const writerStream = new WritableStream({
start(controller) { console.log('开始接受媒体流数据'); }, async write(chunk, controller) { while (sourceBuffer?.updating) { // 等待更新状态稳定 console.log('sourceBuffer 正在更新, 等待更新完成');
await awatingSourceBuffer() }
const arrayBuffer = chunk.buffer console.assert(!sourceBuffer?.updating, 'sourceBuffer 处于异常更新状态')
sourceBuffer?.appendBuffer(arrayBuffer); console.log('写入sourceBuffer完成, 写入大小:', arrayBuffer.byteLength);
}, async close() { console.log('音频数据已接收完毕'); while (sourceBuffer?.updating) { console.log('sourceBuffer 正在更新, 等待更新完成'); await awatingSourceBuffer() } console.log('结束数据流 , 将数据流写入sourceBuffer完成,开始endOfStream'); mediaSource.endOfStream(); loaded = true }, abort(reason) { console.log('SourceBuffer WritableStream aborted:', reason); mediaSource.endOfStream() } }) res.body.pipeTo(writerStream)
})}export function playOrResume() { switch (audioContext.state) { case 'suspended': audioContext.resume() .then(() => { audio.play() }) break case 'running': audioContext.suspend() .then(() => { audio.pause() }) break case 'closed': audioContext.resume() .then(() => { audio.play() }) break }}UI 部分
import { Button } from "~/components/ui/button";import { audio, loadMp3, playOrResume } from "./audiocontext.client";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useEventListener, } from "usehooks-ts";import { HardDriveDownload, Loader2, Pause, Play, RotateCcw } from "lucide-react";export function AudioContextPlayer() { /** * media source extension api */ const audioRefInstance = useRef(audio) const [loading, setLoading] = useState(false) /** * 是否已经准备好播放第一帧数据 */ const [loaded, setLoaded] = useState(false) const [loadedCount, setLoadedCount] = useState(0) const startLoading = useCallback(() => { setLoading(true) loadMp3() }, []) useEffect(() => { setLoadedCount(audio.currentTime) setLoaded(audio.readyState === 4 && audio.buffered.length > 0) }, []) useEventListener('timeupdate', (e) => { setLoadedCount(audio.currentTime) }, audioRefInstance) /** * 是否已经暂停 */ const [paused, setPaused] = useState(true) useEffect(() => { setPaused(audio.paused) }, [])
useEventListener('loadeddata', (e) => { setLoaded(true) setLoading(false) playOrResume() }, audioRefInstance) useEventListener('play', (e) => { setPaused(false) }, audioRefInstance) useEventListener('pause', (e) => { setPaused(true) }, audioRefInstance)
const useControlAudio = useMemo(() => { if (!loaded) { return null } if (paused) { return <Button onClick={() => playOrResume()}> <Play /> 播放 </Button> } return <Button onClick={() => playOrResume()}> <Pause /> 暂停 </Button> }, [loaded, paused]) return <> {!loaded && <Button disabled={loading} onClick={startLoading}> {loading ? <Loader2 className="size-4 animate-spin" /> : <HardDriveDownload />} 加载并播放 </Button>} { useControlAudio } {loaded && <Button className="select-none" disabled={!loadedCount} onClick={() => audioRefInstance.current.currentTime = 0}> <RotateCcw />重新播放 </Button>} </>}