我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~

前言

有过这种窘境没:客厅大屏放着电影,卧室平板补一句台词,结果延迟半秒,配乐“啪嗒”错位;或者聚会时几台音箱一起播歌,鼓点像开火车。哎呀,这谁顶得住!
  别急,今天我就把跨设备音视频同步播放这摊事儿,按“大纲三板斧”掰开:分布式 AV 流 → 网络时钟同步 → 播放控制。咱基于鸿蒙(ArkUI/ArkTS)生态,从系统能力(AVSession、AVPlayer)自研管线(AVSink 作为渲染终端概念)一步步落地。代码我尽量可跑可改,同时也保留“工程级”讨论:抖动缓冲、时钟漂移、弱网补偿、微调速率。放心,风格轻松点儿,技术扎实点儿,又甜又打

一、总览:两种玩法与系统组件分工

先统一下“跨设备同步播放”到底指啥。其实有两条路线:

  1. 协同播放(Coordinated Playback)
    每台设备各自拉源(同一 URL 或同一媒体文件),我们只做时钟对齐与控制同步

    • 优点:省带宽、利用各端硬件解码;
    • 难点:时钟一致性首缓冲对准
  2. 分布式流(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.99x1.01x),在 1020 秒窗口内缓慢纠偏。
  • 方案 C:音频端做时间拉伸(WSOLA/PSOLA),效果最佳,开发量最大。

6.3 关键帧对齐与 GOP

  • 开局/Seek 后,等一个关键帧再放行视频可减小花屏。
  • 若你掌控编码器,建议固定 GOP(例如 1~2 秒一个 IDR),利于统一起播时刻

6.4 MTU 与分片

  • UDP 包尽量控制在 < 1200B(考虑到部分链路/隧道),大帧请分片并在接收端重组。
  • 不要依赖网络层分片,自己做更可控。

6.5 协同播放的“快速路”

  • 如果每台设备都能拉同一 HLS/DASH/Web URL,别推帧

  • 只做三件事:

    1. 对时,取得 nowNtpUs()
    2. 首缓冲到“能播”状态;
    3. 广播:统一在 T=nowNtpUs()+X 起播
  • 定期广播校准点(如每 10s),轻调倍速把误差吸了。


七、测试清单与排障笔记

  • 对时精度:在同一设备上跑 client+server,自检 offset≈0delay 接近 0。跨设备后,稳定在 ±1~3ms 就很香了。
  • 同步误差:两台设备放 1kHz 方波,录音对拍,测音轨差值(理想 < 20ms)。
  • 弱网模拟:限速、抖动(tc/netem)、丢包 1%~3%,观察是否增大了 basePlayoutDelayUs
  • Seek 一致性:在任一端拖动,其他端是否同步切位并在关键帧处恢复;音轨是否没有“嘣”的爆音。
  • 功耗:连续播放 30 分钟,观察 CPU/GPU 占用;若过高,注意解码路径是否硬解循环 tick 是否过频
  • 异常恢复:网络断开 5s 再恢复,是否能自动重新对时、重建抖动缓冲并平滑返回。

八、结语:别再“抢拍”了,优雅同步从今天开始

到这儿,分布式 AV 流(怎么送)、网络时钟同步(怎么齐步)、播放控制(怎么一起听指挥)三件大事都交代完了。我们用 AVSession 做了会话/控制中枢,用 AVPlayer 承担本地解码/协同播放职责,又用一个抽象的 AVSink接收端渲染封装起来,再配上一套轻量 NTP做统一时间基。
  实战里嘛,细节越打磨越顺滑:目标延迟调得合适、倍速微调够温柔、关键帧衔接稳、抖动缓冲会“呼吸”,你就能收获那种一屋子设备齐声合唱的舒适。

(未完待续)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐