第五章:流媒体音频协议 —— 当音频不再是一个文件

前一章我们搞清楚了音频格式与编解码的取舍逻辑。但在实际产品中,用户很少会等一个 40MB 的音频文件完整下载后再开始播放。音乐流媒体、播客、在线电台、语音直播——这些场景的共同特点是:音频不是一次性交付的文件,而是一段持续流动的数据。支撑这一切的,就是流媒体协议。本章从协议原理到工程实战,帮你彻底搞清楚 Web 音频流媒体的技术选型。


一、为什么需要流媒体协议?

在传统的 <audio src="music.mp3"> 模式下,浏览器通过 HTTP 渐进式下载整个文件。这种方式对于短音频完全够用,但面对以下场景就力不从心了:

大文件点播:一个 2 小时的播客可能超过 100MB。用户只想听中间 5 分钟,却需要等待大量无用数据下载。虽然 HTTP Range 请求可以部分解决跳转问题,但无法做到自适应码率。

自适应码率(ABR):用户的网络状况随时在变——从 Wi-Fi 切到 4G、进电梯信号变差、多人共享带宽。如果始终用固定码率,要么高码率导致卡顿,要么低码率浪费了好网速。流媒体协议能让播放器实时根据带宽切换不同质量的音频

直播场景:直播音频没有"完整文件"的概念,数据是实时产生、实时消费的。传统的文件下载模式根本无法工作,必须有一套"边生产边消费"的传输机制。

多语言/多音轨:一个节目可能有中文、英文两条音轨,或者带有音频描述轨道。流媒体协议通过 manifest 文件管理多条轨道,让播放器按需选择。

这些需求催生了 HLS、DASH 等现代流媒体协议。它们的核心思路惊人地一致:把音频切成小片段,用一个描述文件(manifest)告诉播放器这些片段在哪里、是什么质量


二、流媒体传输的核心概念

在深入具体协议之前,先理解几个所有流媒体协议共享的基础概念。

分片(Segmentation)

将完整的音频流切割成若干个固定时长的小片段(通常 2~10 秒),每个片段是一个独立可解码的文件。分片是流媒体的基石——它使得播放器可以按需请求、随时切换码率、从任意位置开始播放。

$$\text{片段数量} = \frac{\text{总时长}}{\text{片段时长}} \quad \text{例:2小时节目 / 10秒 = 720 个片段}$$

分片时长的选择是一个权衡:

片段时长优点缺点
2s切换码率快,启动延迟低片段数量多,manifest 大,请求频繁
6s平衡选择,多数场景适用
10s请求次数少,服务器压力小码率切换慢,直播延迟高

清单文件(Manifest)

清单文件是播放器的"导航地图",描述了所有可用的码率版本和片段地址。HLS 用 .m3u8(文本格式),DASH 用 .mpd(XML 格式)。播放器先请求清单文件,再根据其中的信息按需请求片段。

自适应码率(ABR,Adaptive Bitrate)

播放器在播放过程中持续监测可用带宽缓冲水位,动态在不同码率之间切换。一般策略是:

  • 带宽估算:统计最近几个片段的下载速度
  • 缓冲水位:缓冲区低于阈值时降码率,高于阈值时升码率
  • 切换时机:在片段边界切换,对音频来说几乎无感知

$$\text{选择码率} \leq \text{可用带宽} \times \text{安全系数(通常 0.7~0.8)}$$

点播(VOD)vs 直播(Live)

维度点播(VOD)直播(Live)
manifest固定不变,包含所有片段动态更新,滑动窗口
片段预先生成,存放在 CDN实时生成,持续追加
Seek随意跳转只能在缓冲窗口内跳转
延迟要求关键指标(秒级到亚秒级)

三、HLS 音频流深度解析

HLS(HTTP Live Streaming)由 Apple 在 2009 年推出,是目前使用最广泛的流媒体协议,尤其在 iOS/Safari 生态中是唯一原生支持的自适应流方案

3.1 HLS 的工作原理

HLS 的传输流程可以分为三层:

┌──────────────────────────────────────────────┐
│  编码器 / 转码服务                             │
│  将源音频编码并切成 .aac / .m4s 片段           │
└───────────────┬──────────────────────────────┘
                │ 生成
                ▼
┌──────────────────────────────────────────────┐
│  M3U8 清单文件                                │
│  描述所有码率版本和片段地址                     │
└───────────────┬──────────────────────────────┘
                │ 请求
                ▼
┌──────────────────────────────────────────────┐
│  播放器(浏览器 / hls.js)                     │
│  解析清单 → 选择码率 → 按需请求片段 → 播放     │
└──────────────────────────────────────────────┘

3.2 M3U8 清单格式详解

HLS 使用两级清单结构:**主清单(Master Playlist)**列出所有码率版本,**媒体清单(Media Playlist)**列出某个码率版本的所有片段。

主清单(指向多个码率的媒体清单):

#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS="mp4a.40.2"
lo/playlist.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2"
mid/playlist.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=256000,CODECS="mp4a.40.2"
hi/playlist.m3u8

关键字段解读:

字段含义
BANDWIDTH该码率版本的峰值比特率(bps),播放器据此做 ABR 决策
CODECS编码格式(mp4a.40.2 = AAC-LC)
AVERAGE-BANDWIDTH平均比特率(可选,比 BANDWIDTH 更准确)

媒体清单(某个码率版本的片段列表):

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD

#EXTINF:10.005,
segment_000.aac
#EXTINF:9.998,
segment_001.aac
#EXTINF:10.001,
segment_002.aac
#EXTINF:8.320,
segment_003.aac

#EXT-X-ENDLIST

关键标签含义:

标签含义
#EXT-X-TARGETDURATION最大片段时长(秒),播放器用它计算缓冲策略
#EXT-X-MEDIA-SEQUENCE第一个片段的序号,直播场景中会递增
#EXT-X-PLAYLIST-TYPEVOD(点播,完整列表)或 EVENT(直播,只追加不删除)
#EXTINF下一个片段的实际时长(秒)
#EXT-X-ENDLIST标记播放列表结束(直播流中没有此标签)

3.3 用 FFmpeg 生成纯音频 HLS

# 单码率纯音频 HLS(适合播客)
ffmpeg -i podcast.mp3 \
  -c:a aac -b:a 128k \
  -hls_time 10 \
  -hls_playlist_type vod \
  -hls_segment_filename 'segment_%03d.aac' \
  playlist.m3u8
 
# 多码率音频 HLS(自适应,适合音乐流媒体)
ffmpeg -i music.flac \
  -map 0:a -c:a aac -b:a 64k \
  -hls_time 10 \
  -hls_segment_filename 'lo/seg_%03d.aac' \
  lo/playlist.m3u8
 
ffmpeg -i music.flac \
  -map 0:a -c:a aac -b:a 128k \
  -hls_time 10 \
  -hls_segment_filename 'mid/seg_%03d.aac' \
  mid/playlist.m3u8
 
ffmpeg -i music.flac \
  -map 0:a -c:a aac -b:a 256k \
  -hls_time 10 \
  -hls_segment_filename 'hi/seg_%03d.aac' \
  hi/playlist.m3u8

FFmpeg HLS 关键参数说明:

参数含义
-hls_time 10目标片段时长 10 秒
-hls_playlist_type vod生成 VOD 类型清单(包含 #EXT-X-ENDLIST
-hls_segment_filename片段文件命名模板
-hls_segment_type fmp4使用 fMP4 格式片段(默认是 MPEG-TS)
-hls_flags independent_segments每个片段可独立解码

3.4 前端接入 HLS

Safari 原生支持 HLS,其他浏览器需要 hls.js(底层基于 MSE,第六章详解):

import Hls from 'hls.js';
 
function playHLSAudio(audioEl, url) {
  // Safari 原生支持 HLS
  if (audioEl.canPlayType('application/vnd.apple.mpegurl')) {
    audioEl.src = url;
    return null;
  }
 
  // 其他浏览器用 hls.js(基于 MSE)
  if (Hls.isSupported()) {
    const hls = new Hls({
      maxBufferLength: 60,        // 最大前向缓冲 60 秒
      maxMaxBufferLength: 120,    // 绝对上限 120 秒
      startLevel: -1,             // -1 = 自动选择起始码率
      abrEwmaDefaultEstimate: 500000, // 初始带宽估算(bps)
    });
 
    hls.loadSource(url);
    hls.attachMedia(audioEl);
 
    // 监听码率切换事件
    hls.on(Hls.Events.LEVEL_SWITCHING, (event, data) => {
      console.log(`码率切换 → Level ${data.level}:`,
        hls.levels[data.level].bitrate, 'bps');
    });
 
    // 错误处理
    hls.on(Hls.Events.ERROR, (event, data) => {
      if (data.fatal) {
        switch (data.type) {
          case Hls.ErrorTypes.NETWORK_ERROR:
            console.error('网络错误,尝试恢复');
            hls.startLoad();
            break;
          case Hls.ErrorTypes.MEDIA_ERROR:
            console.error('媒体错误,尝试恢复');
            hls.recoverMediaError();
            break;
          default:
            console.error('不可恢复的错误');
            hls.destroy();
        }
      }
    });
 
    return hls;
  }
 
  console.error('当前浏览器不支持 HLS 播放');
  return null;
}

3.5 HLS 直播清单

直播场景中,媒体清单没有 #EXT-X-ENDLIST,播放器需要定期重新请求清单获取新片段:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:1042

#EXTINF:10.005,
live_seg_1042.aac
#EXTINF:9.998,
live_seg_1043.aac
#EXTINF:10.001,
live_seg_1044.aac

每隔 #EXT-X-TARGETDURATION 秒,播放器重新请求这个清单,发现 MEDIA-SEQUENCE 增加了,就知道有新片段可以下载。旧片段从清单中移除(滑动窗口),保持清单大小不会无限增长。


四、DASH 音频流深度解析

DASH(Dynamic Adaptive Streaming over HTTP)是 MPEG 组织制定的国际标准(ISO/IEC 23009-1),被 YouTube、Netflix、Spotify 等大规模采用。

4.1 DASH vs HLS 的核心差异

维度HLSDASH
制定者AppleMPEG(国际标准)
清单格式.m3u8(文本).mpd(XML)
片段格式MPEG-TS 或 fMP4fMP4(主流)或 WebM
Safari 原生支持
DRMFairPlayWidevine / PlayReady
音视频分轨支持(#EXT-X-MEDIA原生设计(AdaptationSet
时间精度秒级毫秒级

一句话总结:HLS 在 Apple 生态是首选;DASH 是更开放、更灵活的国际标准,在 Android 和 Web 端更主流。

4.2 MPD 清单格式详解

DASH 的 MPD(Media Presentation Description)用 XML 描述,结构比 M3U8 更复杂但也更强大:

<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
     type="static"
     mediaPresentationDuration="PT45M30S"
     minBufferTime="PT6S"
     profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
 
  <Period id="1" duration="PT45M30S">
    <!-- 音频自适应集 -->
    <AdaptationSet mimeType="audio/mp4" codecs="mp4a.40.2"
                   lang="zh" segmentAlignment="true">
 
      <!-- 低码率 -->
      <Representation id="audio-lo" bandwidth="64000" audioSamplingRate="44100">
        <AudioChannelConfiguration
          schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
          value="2"/>
        <BaseURL>audio_64k/</BaseURL>
        <SegmentTemplate
          initialization="init.mp4"
          media="seg_$Number%04d$.m4s"
          startNumber="1"
          duration="10000"
          timescale="1000"/>
      </Representation>
 
      <!-- 中码率 -->
      <Representation id="audio-mid" bandwidth="128000" audioSamplingRate="44100">
        <AudioChannelConfiguration
          schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
          value="2"/>
        <BaseURL>audio_128k/</BaseURL>
        <SegmentTemplate
          initialization="init.mp4"
          media="seg_$Number%04d$.m4s"
          startNumber="1"
          duration="10000"
          timescale="1000"/>
      </Representation>
 
      <!-- 高码率 -->
      <Representation id="audio-hi" bandwidth="256000" audioSamplingRate="44100">
        <AudioChannelConfiguration
          schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
          value="2"/>
        <BaseURL>audio_256k/</BaseURL>
        <SegmentTemplate
          initialization="init.mp4"
          media="seg_$Number%04d$.m4s"
          startNumber="1"
          duration="10000"
          timescale="1000"/>
      </Representation>
 
    </AdaptationSet>
  </Period>
</MPD>

MPD 核心概念层级:

层级含义音频场景示例
MPD整个媒体描述的根节点
Period一个时间段(广告插入点)正片 / 广告
AdaptationSet一组可互相替换的表示(同一内容的不同码率)中文音轨 / 英文音轨
Representation一个具体的编码版本64kbps AAC / 128kbps AAC
SegmentTemplate片段 URL 生成规则seg_0001.m4s, seg_0002.m4s ...

4.3 用 FFmpeg 生成 DASH 音频

# 生成纯音频 DASH(多码率)
ffmpeg -i music.flac \
  -map 0:a -c:a aac -b:a 64k  -f dash -init_seg_name 'lo/init.mp4' -media_seg_name 'lo/seg_$Number%04d$.m4s' \
  -map 0:a -c:a aac -b:a 128k -f dash -init_seg_name 'mid/init.mp4' -media_seg_name 'mid/seg_$Number%04d$.m4s' \
  -map 0:a -c:a aac -b:a 256k -f dash -init_seg_name 'hi/init.mp4' -media_seg_name 'hi/seg_$Number%04d$.m4s' \
  -seg_duration 10 \
  -use_template 1 \
  -use_timeline 0 \
  manifest.mpd

4.4 前端接入 DASH

DASH 在浏览器端通常使用 dash.js(DASH 行业论坛官方参考播放器)或 Shaka Player(Google 开源):

import dashjs from 'dashjs';
 
function playDASHAudio(audioEl, mpdUrl) {
  const player = dashjs.MediaPlayer().create();
 
  player.initialize(audioEl, mpdUrl, /* autoPlay */ false);
 
  // 配置 ABR 策略
  player.updateSettings({
    streaming: {
      abr: {
        initialBitrate: { audio: 128 },    // 初始码率(kbps)
        autoSwitchBitrate: { audio: true }, // 自动切换
      },
      buffer: {
        bufferTimeAtTopQuality: 30,         // 最高码率时的缓冲目标(秒)
        bufferTimeAtTopQualityLongForm: 60, // 长内容的缓冲目标
      },
    },
  });
 
  // 监听码率切换
  player.on(dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, (e) => {
    if (e.mediaType === 'audio') {
      console.log('音频码率切换 →', e.newQuality);
    }
  });
 
  return player;
}

五、Icecast 在线电台

在 HLS/DASH 统治流媒体之前,互联网电台靠的是 Icecast / SHOUTcast 这类传统流媒体服务器。它们的原理更简单粗暴:通过一个永不结束的 HTTP 响应持续发送音频数据

5.1 Icecast 工作原理

┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│   音频源      │ ───→ │  Icecast     │ ───→ │   浏览器      │
│ (DJ 软件 /   │      │  服务器      │      │  <audio>     │
│  自动播放列表)│      │  分发给所有  │      │  直接播放     │
└──────────────┘      │  连接的客户端 │      └──────────────┘
                      └──────────────┘

浏览器可以直接用 &lt;audio&gt; 播放 Icecast 流,无需任何 JavaScript 库——因为 Icecast 发送的就是标准的 MP3 或 AAC 数据流:

<audio src="https://radio.example.com:8000/stream" controls></audio>

5.2 获取电台元数据

Icecast 流本身携带的是纯音频数据,当前曲目信息需要通过 Icecast 的 JSON 状态接口获取:

async function getStreamMetadata(stationUrl) {
  const statusUrl = stationUrl.replace('/stream', '/status-json.xsl');
  const res = await fetch(statusUrl);
  const data = await res.json();
  const source = data.icestats?.source;
  return {
    title:     source?.title || '未知曲目',
    artist:    source?.artist || '',
    listeners: source?.listeners || 0,
    bitrate:   source?.bitrate || 0,
    genre:     source?.genre || '',
  };
}
 
// 定期轮询元数据(曲目切换时更新)
function pollMetadata(stationUrl, interval = 10000) {
  let currentTitle = '';
 
  setInterval(async () => {
    const meta = await getStreamMetadata(stationUrl);
    if (meta.title !== currentTitle) {
      currentTitle = meta.title;
      updateNowPlaying(meta); // 更新 UI
    }
  }, interval);
}

5.3 Icecast 的局限性

维度IcecastHLS/DASH
自适应码率❌ 不支持✅ 核心特性
CDN 分发困难(长连接)简单(标准 HTTP 文件)
Seek 跳转❌ 只能实时收听✅ 支持(VOD)
延迟极低(2~5s)较高(6~30s)
适用场景小型电台、内部广播大规模流媒体分发

Icecast 最大的优势是低延迟和极简接入,最大的劣势是无法利用 CDN 横向扩展。对于听众超过几百人的场景,通常会用 HLS 替代。


六、播客 RSS 与音频分发

播客(Podcast)是音频内容分发的一个独特场景。它不走流媒体协议,而是基于古老但可靠的 RSS 2.0 + iTunes 扩展标准。

6.1 播客 RSS 格式

播客客户端(Apple Podcasts、Spotify、小宇宙等)通过订阅一个 RSS Feed URL 来获取节目列表和音频地址:

<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
     xmlns:podcast="https://podcastindex.org/namespace/1.0"
     version="2.0">
  <channel>
    <title>Web 音频技术播客</title>
    <description>深入 Web Audio API 与音频工程</description>
    <language>zh-cn</language>
    <itunes:author>白川</itunes:author>
    <itunes:image href="https://example.com/cover.jpg"/>
    <itunes:category text="Technology"/>
 
    <item>
      <title>第 1 期:Web Audio API 入门</title>
      <description>从 AudioContext 到完整音频图...</description>
      <enclosure
        url="https://cdn.example.com/ep01.mp3"
        length="45000000"
        type="audio/mpeg"/>
      <itunes:duration>45:32</itunes:duration>
      <itunes:episode>1</itunes:episode>
      <itunes:season>1</itunes:season>
      <pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
      <podcast:chapters url="https://cdn.example.com/ep01-chapters.json"
                        type="application/json+chapters"/>
    </item>
 
    <item>
      <title>第 2 期:音频可视化实战</title>
      <enclosure
        url="https://cdn.example.com/ep02.mp3"
        length="52000000"
        type="audio/mpeg"/>
      <itunes:duration>54:10</itunes:duration>
      <itunes:episode>2</itunes:episode>
      <pubDate>Mon, 08 Jan 2024 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>

关键标签说明:

标签含义
&lt;enclosure&gt;音频文件的 URL、大小和 MIME 类型(播客的核心)
&lt;itunes:duration&gt;节目时长(客户端展示用)
&lt;itunes:image&gt;节目/单集封面
&lt;podcast:chapters&gt;章节标记文件(Podcasting 2.0 标准)

6.2 前端解析播客 RSS

async function parsePodcastFeed(rssUrl) {
  const text = await fetch(rssUrl).then(r => r.text());
  const parser = new DOMParser();
  const doc = parser.parseFromString(text, 'text/xml');
 
  const channel = doc.querySelector('channel');
 
  const items = Array.from(doc.querySelectorAll('item')).map(item => ({
    title:    item.querySelector('title')?.textContent,
    desc:     item.querySelector('description')?.textContent,
    audioUrl: item.querySelector('enclosure')?.getAttribute('url'),
    fileSize: parseInt(item.querySelector('enclosure')?.getAttribute('length') || '0'),
    mimeType: item.querySelector('enclosure')?.getAttribute('type'),
    duration: item.querySelector('itunes\\:duration, duration')?.textContent,
    date:     item.querySelector('pubDate')?.textContent,
    episode:  item.querySelector('itunes\\:episode')?.textContent,
  }));
 
  return {
    title:       channel?.querySelector('title')?.textContent,
    description: channel?.querySelector('description')?.textContent,
    author:      channel?.querySelector('itunes\\:author')?.textContent,
    coverUrl:    channel?.querySelector('itunes\\:image')?.getAttribute('href'),
    items,
  };
}
 
// 使用
const feed = await parsePodcastFeed('https://example.com/podcast/feed.xml');
console.log(`${feed.title} - ${feed.items.length} 集`);
 
feed.items.forEach(ep => {
  console.log(`${ep.title} (${ep.duration}) → ${ep.audioUrl}`);
});

6.3 播客音频的格式选择

播客与音乐流媒体不同,内容以人声为主,对高频细节要求不高,但对文件体积兼容性要求极高(需要兼容所有播客客户端):

格式推荐码率4 分钟体积兼容性推荐场景
MP3 128 kbps128k~3.7 MB✅ 全平台最安全的选择,所有客户端都支持
MP3 64 kbps(单声道)64k~1.8 MB✅ 全平台对话类播客(体积最小)
AAC 64 kbps64k~1.8 MB⚠️ 部分客户端音质优于同码率 MP3
Opus 48 kbps48k~1.4 MB❌ 有限技术上最优,但兼容性不够

Apple Podcasts 官方推荐:MP3(CBR 128 kbps 立体声)或 AAC(64 kbps 立体声)。绝大多数播客至今仍然使用 MP3,因为它的兼容性无可替代。


七、音频直播方案全景对比

音频直播的技术选型,核心考量是延迟。不同场景对延迟的容忍度天差地别:

场景可接受延迟推荐方案
音乐电台/背景播放10~30s标准 HLS
播客直播/语音房2~6sLow-Latency HLS / LL-DASH
连麦互动/语音通话< 500msWebRTC
游戏语音/指挥< 200msWebRTC

7.1 标准 HLS 直播

延迟 = 片段时长 × 缓冲片段数 + 编码延迟。典型配置下(10s 片段 × 3 个缓冲),延迟约 30 秒。

# FFmpeg 推送 HLS 直播流
ffmpeg -f avfoundation -i ":0" \
  -c:a aac -b:a 128k \
  -hls_time 6 \
  -hls_list_size 5 \
  -hls_flags delete_segments \
  -hls_segment_filename 'live/seg_%05d.aac' \
  live/stream.m3u8

7.2 Low-Latency HLS(LL-HLS)

Apple 在 2019 年推出 LL-HLS,通过部分片段(Partial Segments)阻塞式清单请求将延迟降到 2~6 秒:

#EXTM3U
#EXT-X-VERSION:9
#EXT-X-TARGETDURATION:6
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5

#EXTINF:6.000,
seg_100.aac
#EXT-X-PART:DURATION=1.0,URI="seg_101_part0.aac"
#EXT-X-PART:DURATION=1.0,URI="seg_101_part1.aac"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="seg_101_part2.aac"

核心改进:

  • 部分片段(Part):一个完整片段(6s)被进一步细分为多个 Part(1s),播放器不用等完整片段生成就能开始下载
  • 阻塞式清单请求:播放器在请求清单时带上参数,服务器在有新 Part 可用之前不返回响应,避免无效轮询
  • 预加载提示(Preload Hint):告诉播放器下一个即将可用的 Part 地址,提前建立连接

7.3 WebRTC 实时音频

WebRTC 是延迟最低的方案(< 500ms),但架构复杂度也最高。它不走 HTTP,而是基于 UDP + SRTP 直接传输音频数据:

// 简化的 WebRTC 音频收听端
async function listenToStream(signalingUrl) {
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
  });
 
  // 接收远端音频轨道
  pc.ontrack = (event) => {
    const audio = document.getElementById('audioPlayer');
    audio.srcObject = event.streams[0];
    audio.play();
  };
 
  // 通过信令服务器交换 SDP
  const ws = new WebSocket(signalingUrl);
  ws.onmessage = async (msg) => {
    const data = JSON.parse(msg.data);
 
    if (data.type === 'offer') {
      await pc.setRemoteDescription(data);
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      ws.send(JSON.stringify(answer));
    }
 
    if (data.candidate) {
      await pc.addIceCandidate(data.candidate);
    }
  };
 
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({ candidate: event.candidate }));
    }
  };
}

7.4 WebSocket + Web Audio API

一种折中方案——延迟比 HLS 低(1~3s),比 WebRTC 简单。通过 WebSocket 传输编码后的音频数据块,用 Web Audio API 解码播放:

class WSAudioPlayer {
  constructor(wsUrl) {
    this.ctx = new AudioContext();
    this.ws  = new WebSocket(wsUrl);
    this.ws.binaryType = 'arraybuffer';
    this._nextTime = 0;
    this._startOffset = 0.1; // 预缓冲 100ms
  }
 
  start() {
    this.ws.onmessage = async (event) => {
      try {
        const audioBuffer = await this.ctx.decodeAudioData(event.data);
        this._scheduleBuffer(audioBuffer);
      } catch (err) {
        console.warn('解码失败,跳过此块:', err);
      }
    };
  }
 
  _scheduleBuffer(buffer) {
    const source = this.ctx.createBufferSource();
    source.buffer = buffer;
    source.connect(this.ctx.destination);
 
    const now = this.ctx.currentTime;
    const startTime = Math.max(now + this._startOffset, this._nextTime);
    source.start(startTime);
    this._nextTime = startTime + buffer.duration;
  }
 
  stop() {
    this.ws.close();
    this.ctx.close();
  }
}

注意decodeAudioData 需要接收完整的可解码音频块(如一个完整的 MP3 帧或 AAC 帧),不能是任意长度的字节流。服务端需要按帧边界切割数据。


八、实战:Web 音频流媒体协议选型策略

8.1 按场景选协议

音乐流媒体平台(Spotify 模式):

点播:HLS + 多码率(64k / 128k / 256k / 320k)
      → Safari 原生 + hls.js 兜底
      → 或 DASH + Shaka Player
离线:下载完整文件,用 <audio> 直接播放

播客平台(小宇宙模式):

分发:RSS Feed + CDN 托管 MP3 文件
播放:<audio src="..."> 渐进式下载即可
增强:记录播放位置 + 倍速播放 + 章节跳转

在线电台(喜马拉雅 FM 模式):

直播:HLS(6s 片段,延迟 18~30s,可接受)
      → 大量听众场景,CDN 友好
互动:WebRTC(连麦场景,延迟 < 500ms)
回放:转为 VOD HLS

语音社交/Clubhouse 模式

房间音频:WebRTC(多人低延迟)
旁听模式:HLS 转推(降低服务器压力)

游戏内语音

WebRTC(Opus 编码,超低延迟)

8.2 协议能力总览

特性HLSDASHIcecastWebRTCWebSocket
自适应码率❌(需自实现)
CDN 友好
Safari 原生
延迟6~30s2~10s2~5s< 500ms1~3s
DRMFairPlayWidevine
实现复杂度极低
直播支持
点播支持

8.3 音频流媒体的 CDN 部署

HLS/DASH 的最大优势之一是对 CDN 天然友好——所有数据都是标准的 HTTP 静态文件:

源站(Origin)                CDN 边缘节点              客户端
  manifest.mpd    ──缓存──→   manifest.mpd   ──请求──→  播放器
  seg_001.m4s     ──缓存──→   seg_001.m4s    ──请求──→
  seg_002.m4s     ──缓存──→   seg_002.m4s    ──请求──→

缓存策略建议:

文件类型缓存时间原因
主清单(Master)长期(1 天+)码率列表不常变
媒体清单(VOD)长期(1 天+)点播内容不变
媒体清单(Live)不缓存或极短(1s)需要实时更新
媒体片段长期(1 天+)片段内容不变

九、常见问题与排查

"HLS 直播延迟太高"

默认 HLS 延迟 = hls_time × 3 + 编码延迟。降低延迟的方法:

  • 缩短片段时长(-hls_time 2),但会增加请求频率
  • 减少缓冲片段数(hls.js 配置 liveSyncDurationCount: 2
  • 升级到 LL-HLS

"DASH 在 Safari 上不播放"

Safari 不原生支持 DASH。方案:

  • 同时提供 HLS 和 DASH,按浏览器分流
  • 使用同时支持两种协议的播放器(如 Shaka Player)

"Icecast 流在移动端自动断开"

移动浏览器在后台会断开长连接。方案:

  • 使用 Service Worker 保持连接
  • 监听 visibilitychange 事件,页面恢复时重连
  • 考虑迁移到 HLS(片段式请求,对后台更友好)

"WebSocket 音频播放有间断"

通常是因为网络抖动导致数据到达不及时。方案:

  • 增加预缓冲时间(_startOffset
  • 实现 jitter buffer(抖动缓冲区)
  • 监测 _nextTimecurrentTime 的差值,动态调整

十、本章知识图谱

流程图画布 · 115%
Mermaid 流程图加载中...

小结

流媒体协议是音频从"文件"进化为"服务"的关键技术层。HLS 和 DASH 通过分片 + 清单 + ABR 的架构,让音频能在不同网络条件下自适应播放;Icecast 以极简的方式支撑了互联网电台的黄金年代;播客 RSS 用最朴素的分发协议养活了整个播客生态;WebRTC 则把延迟压到了亚秒级,让实时语音成为可能。

没有"最好的协议",只有"最适合场景的协议"——这和上一章音频格式的选择逻辑如出一辙。理解每种协议的延迟特性、扩展能力和实现成本,才能在架构设计时做出正确的技术决策。

下一章我们将深入 Media Source Extensions(MSE)——hls.js 和 dash.js 之所以能在不支持 HLS/DASH 的浏览器上工作,正是因为 MSE 提供了"用 JavaScript 直接向媒体元素喂数据"的底层能力。理解 MSE,就理解了整个 Web 流媒体技术栈的发动机。