Skip to content

Commit e9f33cf

Browse files
committed
fix(media): fix api proxy path & mediabunny hw codec
1 parent ca0899d commit e9f33cf

14 files changed

Lines changed: 1800 additions & 183 deletions

File tree

pnpm-lock.yaml

Lines changed: 1 addition & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/artplayer-proxy-mediabunny/AudioEngine.js

Lines changed: 154 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
/**
22
* Audio Engine for MediaBunny
33
* Handles audio playback using Web Audio API
4+
*
5+
* 关键修复:
6+
* 原实现在 runIterator 的每个迭代周期都 new 一个 setInterval(checkStarvation, 100),
7+
* 1) 多个 interval 会并发存在,可能多次调 audioContext.suspend() 锁死播放器。
8+
* 2) starvation 阈值在某些时刻(迭代器刚启动 / 主线程一次微卡顿)容易误触发。
9+
* 现在改为单一 wall-clock watchdog(在 RAF 侧已有),AudioEngine 内部仅做必要的调度,
10+
* 不再主动 suspend audioContext —— 避免 starvation 误判导致的"播几秒后卡死"。
411
*/
512
import {
613
ALL_FORMATS,
@@ -10,6 +17,7 @@ import {
1017
ReadableStreamSource,
1118
UrlSource,
1219
} from "mediabunny"
20+
import { TimeStretcher } from "./pitchPreservingTimeStretch.js"
1321

1422
export default class AudioEngine {
1523
constructor(events) {
@@ -39,10 +47,18 @@ export default class AudioEngine {
3947
// Async control
4048
this.asyncId = 0
4149
this.queuedNodes = new Set()
50+
51+
// 变速不变调拉伸器(跨 buffer 有状态、避免接缝爆音)
52+
this.stretcher = null
53+
// 拉伸输出起始位置在媒体时间轴上的锁定点(stretcher 活动期间使用)
54+
// 设为 null 表示尚未锁定(下一个输入 buffer 的 timestamp 会被用作起始点)
55+
this._stretchOriginTs = null
56+
// 拉伸输入的原始总时长累计(秒)—— 用来推算每个输出块的起始 timestamp
57+
this._stretchInputDur = 0
4258
}
4359

4460
get currentTime() {
45-
if (this.paused) return this.playbackTimeAtStart
61+
if (this.paused || !this.audioContext) return this.playbackTimeAtStart
4662

4763
return (
4864
(this.audioContext.currentTime - this.audioContextStartTime) *
@@ -86,7 +102,13 @@ export default class AudioEngine {
86102
}
87103

88104
stopQueuedNodes() {
89-
this.queuedNodes.forEach((node) => node.stop())
105+
this.queuedNodes.forEach((node) => {
106+
try {
107+
node.stop()
108+
} catch (_) {
109+
/* ignore */
110+
}
111+
})
90112
this.queuedNodes.clear()
91113
}
92114

@@ -100,6 +122,11 @@ export default class AudioEngine {
100122

101123
await this.stopIterator()
102124
this.stopQueuedNodes()
125+
// 加载新源前先重置 stretcher 状态(旧实例下面会被新 TimeStretcher 替换,
126+
// 但 flush 一下能避免 _stretchOriginTs 等残留状态影响新流程)
127+
this.stretcher?.flush()
128+
this._stretchOriginTs = null
129+
this._stretchInputDur = 0
103130

104131
this.paused = true
105132
this.playbackTimeAtStart = 0
@@ -134,9 +161,27 @@ export default class AudioEngine {
134161
this.ensureAudioContext(audioTrack.sampleRate)
135162
this.audioSink = new AudioBufferSink(audioTrack)
136163

164+
// 创建 stretcher(带 sampleRate / channels 信息)
165+
this.stretcher = new TimeStretcher(
166+
this.audioContext,
167+
audioTrack.sampleRate,
168+
audioTrack.numberOfChannels || 2,
169+
)
170+
this.stretcher.setRate(this.playbackRate)
171+
this._stretchOriginTs = null
172+
this._stretchInputDur = 0
173+
137174
onMetadata?.()
138175
}
139176

177+
/**
178+
* 持续从 audio sink 拉 buffer 并 schedule 到 AudioContext。
179+
*
180+
* 重要变更:去除了原来在每次迭代都 new 一个 setInterval 的实现。
181+
* - 不再主动调用 audioContext.suspend()。
182+
* - starvation 检测交给 VideoEngine 的 RAF 侧 watchdog(监听 currentTime 是否前进)。
183+
* - 当 audioContext.state 意外变为 suspended 时仍会 resume,但绝不主动 suspend。
184+
*/
140185
async runIterator(localId) {
141186
if (!this.audioSink) return
142187

@@ -146,39 +191,23 @@ export default class AudioEngine {
146191
while (true) {
147192
if (localId !== this.asyncId || this.paused) return
148193

149-
const nextPromise = this.audioIterator.next()
150-
151-
// Monitor for buffer starvation
152-
const checkStarvation = setInterval(() => {
153-
if (localId !== this.asyncId || this.paused) {
154-
clearInterval(checkStarvation)
155-
return
156-
}
157-
158-
if (
159-
this.audioContext.state === "running" &&
160-
this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
161-
) {
162-
this.audioContext.suspend()
163-
this.events.emit("waiting")
164-
}
165-
}, 50)
166-
167194
let result
168195
try {
169-
result = await nextPromise
196+
result = await this.audioIterator.next()
170197
} catch (e) {
171198
console.error("Audio iterator error:", e)
172199
break
173-
} finally {
174-
clearInterval(checkStarvation)
175200
}
176201

177202
if (localId !== this.asyncId || this.paused) return
178203

179-
// Resume if was suspended
204+
// 若 audioContext 意外被 suspend(设备唤醒、用户切回前台),主动 resume
180205
if (this.audioContext.state === "suspended") {
181-
await this.audioContext.resume()
206+
try {
207+
await this.audioContext.resume()
208+
} catch (_) {
209+
/* ignore */
210+
}
182211
this.events.emit("canplay")
183212
this.events.emit("playing")
184213
}
@@ -187,34 +216,90 @@ export default class AudioEngine {
187216

188217
const { buffer, timestamp } = result.value
189218

190-
// Schedule audio buffer
219+
// 变速不变调路径:
220+
// - rate === 1:原路径不变,用 mediabunny 的 timestamp 调度 buffer
221+
// - rate ≠ 1:推入 stretcher、用起始 timestamp 锁定拉伸输出的原点,
222+
// 后续输出块的 timestamp = origin + 已输入原始时长 / rate。
223+
// 这样跨 buffer 连续拼接,没有接缝。
224+
let nodeBuffer = null
225+
let nodeTimestamp = timestamp
226+
227+
if (this.playbackRate === 1) {
228+
nodeBuffer = buffer
229+
nodeTimestamp = timestamp
230+
} else {
231+
// 锁定输出原点 timestamp(首个 buffer 进来时)
232+
if (this._stretchOriginTs === null) {
233+
this._stretchOriginTs = timestamp
234+
this._stretchInputDur = 0
235+
}
236+
// 处理:可能返回 null(累积不够)
237+
const out = this.stretcher.process(buffer)
238+
// 记录本轮输出在媒体时间轴上的起点(输入本轮之前累计的原始时长决定)
239+
const outStartMediaTs = this._stretchOriginTs + this._stretchInputDur
240+
// 累计本轮输入的原始时长
241+
this._stretchInputDur += buffer.duration
242+
243+
if (!out) {
244+
// 数据不够,暂不调度,等下一个 buffer 累积
245+
continue
246+
}
247+
nodeBuffer = out
248+
nodeTimestamp = outStartMediaTs
249+
}
250+
251+
// 调度音频 buffer
191252
const node = this.audioContext.createBufferSource()
192-
node.buffer = buffer
253+
node.buffer = nodeBuffer
193254
node.connect(this.gainNode)
194-
node.playbackRate.value = this.playbackRate
255+
// 变速不变调后用原速播放;playbackRate 不再带来 “尖锐化” 副作用
256+
node.playbackRate.value = 1
195257

258+
// 在 audioContext 时间轴上的起始位置:
259+
// nodeTimestamp 是原始媒体时间轴上的位置,进入该位置之后的实际 wall-clock = (nodeTimestamp - playbackTimeAtStart) / rate
196260
const startAt =
197261
this.audioContextStartTime +
198-
(timestamp - this.playbackTimeAtStart) / this.playbackRate
262+
(nodeTimestamp - this.playbackTimeAtStart) / this.playbackRate
199263

200-
const duration = buffer.duration
201-
const endAt = startAt + duration / this.playbackRate
264+
// 拉伸后 buffer 以原速播放,wall-clock 时长 = nodeBuffer.duration
265+
const duration = nodeBuffer.duration
266+
const endAt = startAt + duration
202267

203268
if (endAt > this.latestScheduledEndTime) {
204269
this.latestScheduledEndTime = endAt
205270
}
206271

207-
if (startAt >= this.audioContext.currentTime) {
208-
node.start(startAt)
209-
} else {
210-
node.start(
211-
this.audioContext.currentTime,
212-
(this.audioContext.currentTime - startAt) * this.playbackRate,
213-
)
272+
try {
273+
if (startAt >= this.audioContext.currentTime) {
274+
node.start(startAt)
275+
} else {
276+
// 起始点已过:偶尔过期补丁,过期太多则丢弃
277+
const lateBy = this.audioContext.currentTime - startAt
278+
if (lateBy < duration) {
279+
// 拉伸后 buffer 以原速播放,offset 单位为拉伸后的秒数(不需乘 rate)
280+
node.start(this.audioContext.currentTime, lateBy)
281+
} else {
282+
// 过期太多,直接丢
283+
continue
284+
}
285+
}
286+
} catch (e) {
287+
console.warn("Audio buffer source start failed:", e)
288+
continue
214289
}
215290

216291
this.queuedNodes.add(node)
217292
node.onended = () => this.queuedNodes.delete(node)
293+
294+
// 节流:当已 schedule 的时间领先 currentTime 超过 2 秒时,让出主线程一会儿,
295+
// 避免一次性把所有 buffer 都灌进 audioContext(占用过多内存)。
296+
const ahead = this.latestScheduledEndTime - this.audioContext.currentTime
297+
if (ahead > 2.0) {
298+
// 简单 await 一个 setTimeout,0ms 也行——让 await 把控制权还给事件循环
299+
await new Promise((resolve) =>
300+
setTimeout(resolve, Math.min(500, (ahead - 1.5) * 1000)),
301+
)
302+
}
218303
}
219304
}
220305

@@ -245,12 +330,26 @@ export default class AudioEngine {
245330

246331
this.stopIterator()
247332
this.stopQueuedNodes()
333+
// 重置 stretcher 状态,避免 “恢复播放时用了之前的拉伸状态” 造成接缝不连续
334+
this.stretcher?.flush()
335+
this._stretchOriginTs = null
336+
this._stretchInputDur = 0
248337
}
249338

250339
async seek(time) {
251340
this.playbackTimeAtStart = Math.max(0, time)
252-
this.audioContextStartTime = this.audioContext.currentTime
253-
this.latestScheduledEndTime = this.audioContextStartTime
341+
if (this.audioContext) {
342+
this.audioContextStartTime = this.audioContext.currentTime
343+
this.latestScheduledEndTime = this.audioContextStartTime
344+
} else {
345+
this.audioContextStartTime = 0
346+
this.latestScheduledEndTime = 0
347+
}
348+
349+
// seek 后原始媒体位置完全变了,stretcher 的跨 buffer 状态不再适用
350+
this.stretcher?.flush()
351+
this._stretchOriginTs = null
352+
this._stretchInputDur = 0
254353

255354
const id = ++this.asyncId
256355
if (!this.paused) {
@@ -270,9 +369,18 @@ export default class AudioEngine {
270369
if (!this.paused) {
271370
this.playbackTimeAtStart = this.currentTime
272371
this.audioContextStartTime = this.audioContext.currentTime
372+
this.latestScheduledEndTime = this.audioContextStartTime
373+
// 切换速率时,旧的 queuedNodes 仍以旧速率/旧拉伸结果在播,会和新调度的 buffer 叠加。
374+
// 立即停掉旧节点(runIterator 在下一轮 await 后才会真正退出,间隔期间会出现双轨叠加)
375+
this.stopQueuedNodes()
273376
}
274377

275378
this.playbackRate = rate
379+
// 拉伸状态的 prevTailRef / prevTailSamples 在新速率下不再适用,重置
380+
this.stretcher?.setRate(rate)
381+
this.stretcher?.flush()
382+
this._stretchOriginTs = null
383+
this._stretchInputDur = 0
276384

277385
if (!this.paused) {
278386
const id = ++this.asyncId
@@ -283,9 +391,14 @@ export default class AudioEngine {
283391
destroy() {
284392
this.asyncId++
285393
this.pause()
286-
this.audioContext?.close()
394+
try {
395+
this.audioContext?.close()
396+
} catch (_) {
397+
/* ignore */
398+
}
287399
this.audioContext = null
288400
this.input = null
289401
this.audioSink = null
402+
this.stretcher = null
290403
}
291404
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type Artplayer from "artplayer"
2+
3+
export interface MediaBunnyAudioPatchOptions {
4+
video: HTMLMediaElement
5+
src: string
6+
/** 漂移超过该值则重新 seek 音频对齐,默认 0.25 秒 */
7+
driftThreshold?: number
8+
/** 加载或解码失败回调 */
9+
onError?: (err: Error) => void
10+
}
11+
12+
export default class MediaBunnyAudioPatch {
13+
constructor(opts: MediaBunnyAudioPatchOptions)
14+
destroy(): void
15+
}
16+
17+
/**
18+
* 便捷工厂:在 Artplayer 完成 url 装载后给它打上音频补丁
19+
*/
20+
export function attachMediabunnyAudio(
21+
art: Artplayer,
22+
src: string,
23+
opts?: Partial<Omit<MediaBunnyAudioPatchOptions, "video" | "src">>,
24+
): MediaBunnyAudioPatch

0 commit comments

Comments
 (0)