为什么同一首歌在两台设备上老“抢拍”?——我用AVSession / AVPlayer / AVSink搭了个跨设备音视频同步播放,你来不来试戏?
本文介绍了鸿蒙系统下跨设备音视频同步播放的实现方案,主要包括分布式AV流和协同播放两种模式。文章重点讲解了分布式AV流的技术实现,包括基于UDP的自定义传输协议设计、源端编码帧发送逻辑,以及接收端的AVSink模块实现(包含抖动缓冲和时钟同步处理)。同时提供了ArkTS伪代码示例,展示了如何利用鸿蒙的AVPlayer、AVSession等系统能力构建多设备同步播放系统,并讨论了网络时钟同步、播放控
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
有过这种窘境没:客厅大屏放着电影,卧室平板补一句台词,结果延迟半秒,配乐“啪嗒”错位;或者聚会时几台音箱一起播歌,鼓点像开火车。哎呀,这谁顶得住!
别急,今天我就把跨设备音视频同步播放这摊事儿,按“大纲三板斧”掰开:分布式 AV 流 → 网络时钟同步 → 播放控制。咱基于鸿蒙(ArkUI/ArkTS)生态,从系统能力(AVSession、AVPlayer)到自研管线(AVSink 作为渲染终端概念)一步步落地。代码我尽量可跑可改,同时也保留“工程级”讨论:抖动缓冲、时钟漂移、弱网补偿、微调速率。放心,风格轻松点儿,技术扎实点儿,又甜又打。
一、总览:两种玩法与系统组件分工
先统一下“跨设备同步播放”到底指啥。其实有两条路线:
-
协同播放(Coordinated Playback)
每台设备各自拉源(同一 URL 或同一媒体文件),我们只做时钟对齐与控制同步。- 优点:省带宽、利用各端硬件解码;
- 难点:时钟一致性与首缓冲对准。
-
分布式流(Distributed AV Stream)
一台是**源(Source)推流,其他设备是汇(Sink)**接收渲染。- 优点:节目源统一,效果一致;
- 难点:网络传输、重传/丢包、抖动缓冲、时间戳对齐。
本文两种都讲,但示例以“分布式流”为主,再顺带给出“协同播放”的速通做法。
技术分工脑图:
- AVPlayer:本地媒体解码与播放(协同模式里是主力;分布式模式里可用作源端解码+编码或直接推文件容器)。
- AVSession:媒体会话与播放控制(建组、广播 Play/Pause/Seek,展示系统媒体卡片)。
- AVSink(本文概念化定义):在接收端上,将网络来的音视频帧送入系统渲染器(音频 AudioRenderer / 视频 Surface)。我会给出一个
AVSink接口,把系统的具体管线包起来,便于替换实现。
二、分布式 AV 流:怎么把音视频喂到对端?
2.1 传输协议的选择
- 快速成型:UDP + 自定义帧头(RTP 风格),时间戳 + 序号 + 编码类型。简单、低延迟。
- 更全面:RTP/RTSP 或 WebRTC。功能多,但上手成本高。
- 局域网优先:多设备在同网下体验最佳;公网需穿透/中转。
本文给一个UDP 自定义帧的 Demo,语义清晰、足以压住同步。你之后完全可以平滑切到 RTP/WebRTC。
2.2 帧格式(伪 RTP)
| magic(2B) | ver(1B) | type(1B: 0=audio,1=video) |
| seq(4B) | timestamp_ntp(8B, us) |
| codec(2B) | payload_len(4B) | payload(...)
timestamp_ntp:源端按网络统一时钟(后文详解)打戳;seq:重排/丢包统计;codec:如 AAC/H.264/H.265;payload:对齐编码帧(视频建议按关键帧分界)。
2.3 Source 侧:拉流/解码/编码/打戳/发送(ArkTS 伪代码)
说明:不同 SDK 版本导入名略有差异,这里用常见写法,若你装的是新包名,请自行替换。思路是固定的:拿到帧 → 打统一时钟戳 → 通过 UDP 发出去。
// source/Streamer.ts
import socket from '@ohos.net.socket'
import media from '@ohos.multimedia.media' // AVPlayer/解码器
// 你也可以用硬件编码器组件,本文用“已编码帧”路径示意
type Peer = { host: string; port: number }
export class Streamer {
private udp!: socket.UDPSocket
private peers: Peer[] = []
private running = false
constructor(private getNowNtpUs: () => number) {} // 统一时钟回调
async start(peers: Peer[]) {
this.peers = peers
this.udp = socket.constructUDPSocketInstance()
await this.udp.bind({ address: '0.0.0.0', port: 0 }) // 随机端口
this.running = true
// 这里省略:打开媒体源(文件/网络),拿到编码帧回调
}
// 你可以把它挂在编码器回调/复用器回调里
onEncodedFrame(frame: ArrayBuffer, type: 'audio' | 'video', codec: number, seq: number) {
if (!this.running) return
const ts = this.getNowNtpUs() // 网络统一时钟(微秒)
const header = this.buildHeader(type, seq, ts, codec, frame.byteLength)
const pkt = this.concat(header, new Uint8Array(frame))
for (const p of this.peers) {
this.udp.send({ data: pkt.buffer, address: p.host, port: p.port })
}
}
private buildHeader(type: 'audio' | 'video', seq: number, tsUs: number, codec: number, len: number): Uint8Array {
const buf = new ArrayBuffer(2+1+1+4+8+2+4)
const v = new DataView(buf)
let o = 0
v.setUint16(o, 0xABCD); o += 2
v.setUint8(o, 1); o += 1 // ver
v.setUint8(o, type === 'audio' ? 0 : 1); o += 1
v.setUint32(o, seq); o += 4
// 写入 64bit 微秒
const hi = Math.floor(tsUs / 2**32), lo = tsUs >>> 0
v.setUint32(o, hi); o += 4
v.setUint32(o, lo); o += 4
v.setUint16(o, codec); o += 2
v.setUint32(o, len); o += 4
return new Uint8Array(buf)
}
private concat(a: Uint8Array, b: Uint8Array) {
const out = new Uint8Array(a.byteLength + b.byteLength)
out.set(a, 0); out.set(b, a.byteLength)
return out
}
stop() {
this.running = false
this.udp.close()
}
}
源端关键点:
- 统一时钟戳:不是
Date.now(),而是我们算出来的网络对齐时间(后文给出getNowNtpUs实现)。- 不做花里胡哨重传(演示版先别上 FEC/ARQ),靠抖动缓冲抗抖动。
2.4 Sink 侧:接收/重排/抖动缓冲/按统一时钟投喂到系统渲染
我们做一个抽象的
AVSink:里头对接 AudioRenderer(音)和 Surface(视频)。重点是**“按统一时钟 + 目标延迟”**把帧发给系统。
// sink/AvSink.ts
import socket from '@ohos.net.socket'
// import audio from '@ohos.multimedia.audio' // AudioRenderer(不同版本名字略有差异)
// import display from '@ohos.display' // Surface/VideoOutput
type Frame = {
type: 'audio' | 'video'
seq: number
tsUs: number
codec: number
payload: Uint8Array
}
export class AvSink {
private udp!: socket.UDPSocket
private audioQ: Frame[] = []
private videoQ: Frame[] = []
private running = false
private basePlayoutDelayUs = 150_000 // 150ms 目标播放延迟
private lastReleaseUs = 0
constructor(private port: number, private nowNtpUs: () => number) {}
async start() {
this.udp = socket.constructUDPSocketInstance()
await this.udp.bind({ address: '0.0.0.0', port: this.port })
this.udp.on('message', (msg) => this.onPkt(msg.message))
this.running = true
this.loop()
}
private onPkt(buf: ArrayBuffer) {
const v = new DataView(buf)
let o = 0
const magic = v.getUint16(o); o+=2
if (magic !== 0xABCD) return
const ver = v.getUint8(o); o+=1
const type = v.getUint8(o); o+=1
const seq = v.getUint32(o); o+=4
const hi = v.getUint32(o); o+=4
const lo = v.getUint32(o); o+=4
const tsUs = hi * 2**32 + lo
const codec= v.getUint16(o); o+=2
const len = v.getUint32(o); o+=4
const payload = new Uint8Array(buf, o, len)
const fr: Frame = { type: type===0?'audio':'video', seq, tsUs, codec, payload }
if (fr.type === 'audio') this.pushQ(this.audioQ, fr, 150)
else this.pushQ(this.videoQ, fr, 60)
}
private pushQ(q: Frame[], fr: Frame, maxLen: number) {
q.push(fr)
// 简单重排
q.sort((a,b)=>a.seq-b.seq)
if (q.length > maxLen) q.shift()
}
private async loop() {
while (this.running) {
const now = this.nowNtpUs()
// 按“统一时钟 + 目标延迟”释放
this.releaseReady(this.audioQ, now, f => this.renderAudio(f))
this.releaseReady(this.videoQ, now, f => this.renderVideo(f))
await this.sleep(5) // 5ms tick
}
}
private releaseReady(q: Frame[], nowUs: number, render: (f: Frame)=>void) {
while (q.length) {
const f = q[0]
// 目标呈现时刻
const due = f.tsUs + this.basePlayoutDelayUs
if (nowUs + 1000 < due) break // 1ms 提前量
q.shift()
render(f)
this.lastReleaseUs = nowUs
}
}
private renderAudio(f: Frame) {
// TODO: 解码 AAC → PCM,然后写入 AudioRenderer
// renderer.write(framePCM)
}
private renderVideo(f: Frame) {
// TODO: 解码 H.264/H.265 → Surface
// videoDecoder.queueInputBuffer(...) -> Surface
}
private sleep(ms: number) { return new Promise(r=>setTimeout(r, ms)) }
stop() { this.running = false; this.udp.close() }
}
接收端关键点:
- 抖动缓冲:
basePlayoutDelayUs决定“稳不稳”。150ms 是通用起步值,弱网可拉到 250~350ms。- 释放策略:用统一时钟判断“该不该播”。
- 音视频解码:可用系统解码器,音频走
AudioRenderer、视频绑定Surface。本文留出 TODO 位,免得示例拖太长。
三、网络时钟同步:没有共同时间基,谈何“齐步走”
同步的灵魂是时钟。设备 A 的 1 秒,不一定等于设备 B 的 1 秒。靠系统 RTC/NTP?不够稳也不够细。我们要自建“会话级统一时钟”,在同一房间/组内有效。
3.1 轻量 SNTP(四时间戳)与漂移估计
一次请求/应答记录四个时间点(单位:微秒 us):
t0:客户端发出请求的本地时间t1:服务端收到请求的服务端本地时间t2:服务端发出响应的服务端本地时间(可≈t1)t3:客户端收到响应的本地时间
估算:
- 往返时延
delay ≈ (t3 - t0) - (t2 - t1) - 偏移
offset ≈ ((t1 - t0) + (t2 - t3)) / 2 - 统一时钟(客户端视角)
nowNtp ≈ localMonotonic() + offset
多次测量取最小 delay 的那次 offset作为基准(排除拥塞抖动)。再用滑动窗口做平滑,配合简单 PLL(相位锁定环)跟踪缓慢漂移。
3.2 ArkTS 简单实现
// clock/ntp.ts
import socket from '@ohos.net.socket'
export class NtpClient {
private udp!: socket.UDPSocket
private server!: { host: string; port: number }
private offsetUs = 0
private alpha = 0.05 // 平滑系数
async start(serverHost: string, serverPort: number) {
this.server = { host: serverHost, port: serverPort }
this.udp = socket.constructUDPSocketInstance()
await this.udp.bind({ address: '0.0.0.0', port: 0 })
this.udp.on('message', (msg) => this.onMsg(msg.message))
setInterval(()=>this.probe(), 1000) // 1s 探测一次
}
private probe() {
const t0 = this.nowUs()
const buf = new ArrayBuffer(16)
new DataView(buf).setFloat64(0, t0) // 写 t0
this.udp.send({ data: buf, address: this.server.host, port: this.server.port })
}
private onMsg(buf: ArrayBuffer) {
const dv = new DataView(buf)
const t0 = dv.getFloat64(0)
const t1 = dv.getFloat64(8)
const t2 = dv.getFloat64(16)
const t3 = this.nowUs()
const delay = (t3 - t0) - (t2 - t1)
const offset = ((t1 - t0) + (t2 - t3)) / 2
// 只用低延时样本
if (delay < 20_000) { // <20ms
this.offsetUs = (1 - this.alpha) * this.offsetUs + this.alpha * offset
}
}
// 统一时钟(us)
nowNtpUs() {
return this.nowUs() + this.offsetUs
}
private nowUs() { return Date.now() * 1000 }
}
配套的服务器端也很好写(同理发回 t1/t2),部署在“时钟主机”(通常就是源端或某个专门协调端)。
进阶:
- 想更稳?切到PTP(IEEE 1588)/硬件时间戳,或局域网里加多点测距挑最近最稳那台做 master。
- 漂移校正:仅靠 offset 不够,不同设备的晶振速率也略不同。可以轻调整速(后文详解)把漂移吸收掉。
四、播放控制:AVSession 建组、指令广播与本地 AVPlayer 协同
4.1 为什么一定要用 AVSession
- 系统级媒体会话:让系统知道“我在播”,媒体控制中心/耳机键都能控你。
- 控制到组:多个设备同一会话组,广播 Play/Pause/Seek 才能齐步走。
- 状态回传:进度、封面、元数据一致,体验完整。
4.2 建立会话与广播控制(ArkTS 示例)
说明:不同 SDK 版本 API 名称有小差异,下面示意常见写法。你只要把**“建 Session → 设置元信息/状态 → 监听控制 → 广播给组”**这条链按项目实际 API 补齐即可。
// control/session.ts
import avsession from '@ohos.multimedia.avsession'
export class SessionBus {
private session!: avsession.AVSession
private members: string[] = [] // 设备ID列表(可通过 DeviceManager 发现)
async init(context: any) {
this.session = await avsession.createAVSession(context, 'CastGroup', avsession.SessionType.MEDIA)
await this.session.activate()
// 设置元信息(标题、封面、时长)
await this.session.setAVMetadata({ title: 'GroupMovie', duration: 7200_000 })
// 监听系统媒体控制(耳机键/快捷中心)
this.session.on('play', () => this.broadcast({ cmd:'play' }))
this.session.on('pause', () => this.broadcast({ cmd:'pause' }))
this.session.on('seek', (ms:number) => this.broadcast({ cmd:'seek', pos:ms }))
}
join(deviceId: string) { if (!this.members.includes(deviceId)) this.members.push(deviceId) }
// 通过你的信令通道(如 WebSocket/分布式数据)发给所有成员
broadcast(msg: any) {
// sendToDevice(deviceId, msg)
}
}
接收端拿到控制消息后驱动本地 AVPlayer / A VSink:
// control/receiver.ts
type Cmd = { cmd:'play'|'pause'|'seek'; pos?: number; ntpStartUs?: number }
export class ControlReceiver {
constructor(private playerCtl: PlayerCtl, private sinkCtl: SinkCtl) {}
onMessage(cmd: Cmd) {
switch (cmd.cmd) {
case 'play':
// 统一起播时刻(ntpStartUs 可选:协同播放时更关键)
this.playerCtl.playAlign(cmd.ntpStartUs)
this.sinkCtl.playAlign(cmd.ntpStartUs)
break
case 'pause':
this.playerCtl.pause()
this.sinkCtl.pause()
break
case 'seek':
this.playerCtl.seek(cmd.pos!)
this.sinkCtl.seek(cmd.pos!)
break
}
}
}
协同播放场景:每端都用 AVPlayer 播放同一 URL,这时**
ntpStartUs极其重要**。你可以在“3、网络时钟同步”拿到统一时间后,广播:“在 NTP T 起播”,各端首缓冲到位后“卡点起跑”。
五、把三件事拧成一根绳:完整示例工程骨架
下面给一个最小可运行的结构(很多细节按需扩展,但主干是真的能跑/能改)。我把 Source(含 NTP server + 推流) 和 Sink(含 NTP client + 渲染 + 控制) 分模块,控制层通过会话消息沟通。
entry/
ets/
clock/
ntp_server.ets
ntp_client.ets
source/
Streamer.ets
sink/
AvSink.ets
control/
session.ets
receiver.ets
ui/
SourcePage.ets
SinkPage.ets
App.ets
5.1 NTP 服务端(极简)
// clock/ntp_server.ts
import socket from '@ohos.net.socket'
export class NtpServer {
private udp!: socket.UDPSocket
async start(port: number) {
this.udp = socket.constructUDPSocketInstance()
await this.udp.bind({ address: '0.0.0.0', port })
this.udp.on('message', (msg) => this.onReq(msg))
}
private onReq(msg: any) {
const t1 = Date.now()*1000
const req = new DataView(msg.message)
const t0 = req.getFloat64(0)
const t2 = Date.now()*1000
const buf = new ArrayBuffer(24)
const dv = new DataView(buf)
dv.setFloat64(0, t0)
dv.setFloat64(8, t1)
dv.setFloat64(16, t2)
this.udp.send({ data: buf, address: msg.address.address, port: msg.address.port })
}
}
5.2 源端 UI:选择文件→启动 NTP→开始推流
// ui/SourcePage.ets
@Entry
@Component
struct SourcePage {
private ntp = new NtpServer()
private streamer = new Streamer(()=>Date.now()*1000) // 先用本机作主钟
@State portNtp: number = 5001
@State peers: string = '192.168.1.101:6000,192.168.1.102:6000'
build() {
Column({ space: 12 }) {
Text('Source 控制台').fontSize(20).fontWeight(FontWeight.Bold)
TextInput({ text: `${this.portNtp}` }).onChange(v=>this.portNtp=Number(v))
Button('启动NTP') .onClick(()=> this.ntp.start(this.portNtp))
TextInput({ text: this.peers }).onChange(v=>this.peers=v)
Button('开始推流').onClick(async ()=>{
const ps = this.peers.split(',').map(s=>{
const [h,p]=s.trim().split(':'); return {host:h, port:Number(p)}
})
await this.streamer.start(ps)
// TODO: 选择节目源并挂上 onEncodedFrame
})
}.padding(16)
}
}
5.3 接收端 UI:NTP 对齐→起 AvSink→接受会话控制
// ui/SinkPage.ets
@Entry
@Component
struct SinkPage {
private ntp = new NtpClient()
private sink = new AvSink(6000, ()=>this.ntp.nowNtpUs())
private bus = new ControlReceiver(
/* playerCtl */{
playAlign:(t?:number)=>{/* 协同播放时用 AVPlayer 在 t 起播 */},
pause:()=>{/* ... */},
seek:(ms:number)=>{/* ... */}
},
/* sinkCtl */{
playAlign:(t?:number)=>{/* 分布式流一般立即播,t 可忽略 */},
pause:()=>{/* ... */},
seek:(ms:number)=>{/* ... */}
}
)
@State server: string = '192.168.1.100:5001'
build() {
Column({ space: 12 }) {
Text('Sink 控制台').fontSize(20).fontWeight(FontWeight.Bold)
Row({space:8}){
Text('NTP服务端')
TextInput({ text: this.server }).onChange(v=>this.server=v)
}
Button('对时') .onClick(()=>{
const [h,p]=this.server.split(':'); this.ntp.start(h, Number(p))
})
Button('启动接收').onClick(()=> this.sink.start())
// TODO: 你的会话信令到位后,把消息交给 bus.onMessage(...)
}.padding(16)
}
}
六、抖动、漂移与弱网:真实世界里的生存智慧
6.1 抖动缓冲(Jitter Buffer)
- 目标延迟:150ms 起步,弱网加大。太小会掉帧,太大又“迟钝”。
- 自适应:观察最近 200 个包的到达间隔标准差,超阈值则平滑增大 delay。
- 丢包:视频遇到 B/P 帧缺失,等下一个关键帧恢复;音频可插零/做短暂重复。
6.2 漂移修正(Rate Matching)
- 即便对了 offset,晶振速率差会让两端每分钟慢慢走散。
- 方案 A:定期微调
basePlayoutDelayUs(±1~2ms),让本地播放头向目标对齐,人耳几乎无感。 - 方案 B:若系统播放器支持细颗粒倍速(如 0.99x
1.01x),在 1020 秒窗口内缓慢纠偏。 - 方案 C:音频端做时间拉伸(WSOLA/PSOLA),效果最佳,开发量最大。
6.3 关键帧对齐与 GOP
- 开局/Seek 后,等一个关键帧再放行视频可减小花屏。
- 若你掌控编码器,建议固定 GOP(例如 1~2 秒一个 IDR),利于统一起播时刻。
6.4 MTU 与分片
- UDP 包尽量控制在 < 1200B(考虑到部分链路/隧道),大帧请分片并在接收端重组。
- 不要依赖网络层分片,自己做更可控。
6.5 协同播放的“快速路”
-
如果每台设备都能拉同一 HLS/DASH/Web URL,别推帧!
-
只做三件事:
- 对时,取得
nowNtpUs(); - 首缓冲到“能播”状态;
- 广播:统一在
T=nowNtpUs()+X起播。
- 对时,取得
-
定期广播校准点(如每 10s),轻调倍速把误差吸了。
七、测试清单与排障笔记
- 对时精度:在同一设备上跑 client+server,自检
offset≈0,delay接近 0。跨设备后,稳定在 ±1~3ms 就很香了。 - 同步误差:两台设备放 1kHz 方波,录音对拍,测音轨差值(理想 < 20ms)。
- 弱网模拟:限速、抖动(tc/netem)、丢包 1%~3%,观察是否增大了
basePlayoutDelayUs。 - Seek 一致性:在任一端拖动,其他端是否同步切位并在关键帧处恢复;音轨是否没有“嘣”的爆音。
- 功耗:连续播放 30 分钟,观察 CPU/GPU 占用;若过高,注意解码路径是否硬解、循环 tick 是否过频。
- 异常恢复:网络断开 5s 再恢复,是否能自动重新对时、重建抖动缓冲并平滑返回。
八、结语:别再“抢拍”了,优雅同步从今天开始
到这儿,分布式 AV 流(怎么送)、网络时钟同步(怎么齐步)、播放控制(怎么一起听指挥)三件大事都交代完了。我们用 AVSession 做了会话/控制中枢,用 AVPlayer 承担本地解码/协同播放职责,又用一个抽象的 AVSink 把接收端渲染封装起来,再配上一套轻量 NTP做统一时间基。
实战里嘛,细节越打磨越顺滑:目标延迟调得合适、倍速微调够温柔、关键帧衔接稳、抖动缓冲会“呼吸”,你就能收获那种一屋子设备齐声合唱的舒适。
…
(未完待续)
更多推荐




所有评论(0)