mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 09:41:20 -08:00
refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle
This commit is contained in:
1 parent
9d037dcbf9
commit
ccee9157a7
30 files changed
+16182
-736
No files matched your search
@@ -1,6 +0,0 @@
|
||||
import type { AudioPort } from './engine-ports'
|
||||
import type EventTarget from './EventTarget'
|
||||
|
||||
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
|
||||
declare const AudioEngine: new (events: EventTarget) => AudioPort
|
||||
export default AudioEngine
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Audio Engine for MediaBunny
|
||||
* Handles audio playback using Web Audio API
|
||||
*/
|
||||
import {
|
||||
AudioBufferSink,
|
||||
} from 'mediabunny'
|
||||
|
||||
export default class AudioEngine {
|
||||
constructor(events) {
|
||||
this.events = events
|
||||
|
||||
// MediaBunny instances
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.audioIterator = null
|
||||
|
||||
// Web Audio API
|
||||
this.audioContext = null
|
||||
this.gainNode = null
|
||||
|
||||
// Playback state
|
||||
this.audioContextStartTime = 0
|
||||
this.playbackTimeAtStart = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
this.duration = Number.NaN
|
||||
this.paused = true
|
||||
|
||||
// Audio settings
|
||||
this.volume = 0.7
|
||||
this.muted = false
|
||||
this.playbackRate = 1
|
||||
|
||||
// Async control
|
||||
this.asyncId = 0
|
||||
this.queuedNodes = new Set()
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
if (this.paused)
|
||||
return this.playbackTimeAtStart
|
||||
|
||||
return (
|
||||
(this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate
|
||||
+ this.playbackTimeAtStart
|
||||
)
|
||||
}
|
||||
|
||||
ensureAudioContext(sampleRate) {
|
||||
if (this.audioContext)
|
||||
return
|
||||
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
|
||||
try {
|
||||
this.audioContext = new AudioContext({ sampleRate })
|
||||
}
|
||||
catch {
|
||||
this.audioContext = new AudioContext()
|
||||
}
|
||||
|
||||
this.gainNode = this.audioContext.createGain()
|
||||
this.gainNode.connect(this.audioContext.destination)
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
updateGain() {
|
||||
if (!this.gainNode)
|
||||
return
|
||||
const v = this.muted ? 0 : this.volume
|
||||
this.gainNode.gain.value = v * v
|
||||
}
|
||||
|
||||
stopQueuedNodes() {
|
||||
this.queuedNodes.forEach(node => node.stop())
|
||||
this.queuedNodes.clear()
|
||||
}
|
||||
|
||||
async stopIterator() {
|
||||
await this.audioIterator?.return()
|
||||
this.audioIterator = null
|
||||
}
|
||||
|
||||
handleNoAudioTrack() {
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
async load(media, onMetadata) {
|
||||
++this.asyncId
|
||||
|
||||
await this.stopIterator()
|
||||
this.stopQueuedNodes()
|
||||
|
||||
this.paused = true
|
||||
this.playbackTimeAtStart = 0
|
||||
this.audioContextStartTime = 0
|
||||
|
||||
const { input, audioTrack, duration } = media
|
||||
this.input = input
|
||||
this.duration = duration
|
||||
|
||||
if (!audioTrack) {
|
||||
this.handleNoAudioTrack()
|
||||
onMetadata?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (audioTrack.codec === null || !(await audioTrack.canDecode())) {
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
onMetadata?.()
|
||||
return
|
||||
}
|
||||
|
||||
this.ensureAudioContext(audioTrack.sampleRate)
|
||||
this.audioSink = new AudioBufferSink(audioTrack)
|
||||
|
||||
onMetadata?.()
|
||||
}
|
||||
|
||||
async runIterator(localId) {
|
||||
if (!this.audioSink)
|
||||
return
|
||||
|
||||
await this.stopIterator()
|
||||
this.audioIterator = this.audioSink.buffers(this.currentTime)
|
||||
|
||||
// Batch size: read multiple audio buffers per iteration to reduce
|
||||
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
|
||||
const BATCH_SIZE = 16
|
||||
|
||||
while (true) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
|
||||
const batch = []
|
||||
let batchDone = false
|
||||
try {
|
||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
||||
const result = await this.audioIterator.next()
|
||||
if (result.done) {
|
||||
batchDone = true
|
||||
break
|
||||
}
|
||||
batch.push(result.value)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Audio iterator error:', e)
|
||||
batchDone = true
|
||||
}
|
||||
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
|
||||
// Resume if was suspended
|
||||
if (batch.length > 0 && this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume()
|
||||
this.events.emit('canplay')
|
||||
this.events.emit('playing')
|
||||
}
|
||||
|
||||
// Schedule all buffers in the batch
|
||||
for (const { buffer, timestamp } of batch) {
|
||||
const node = this.audioContext.createBufferSource()
|
||||
node.buffer = buffer
|
||||
node.connect(this.gainNode)
|
||||
node.playbackRate.value = this.playbackRate
|
||||
|
||||
const startAt
|
||||
= this.audioContextStartTime
|
||||
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
|
||||
|
||||
const duration = buffer.duration
|
||||
const endAt = startAt + duration / this.playbackRate
|
||||
|
||||
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
|
||||
if (endMediaTime > this.latestScheduledEndTime) {
|
||||
this.latestScheduledEndTime = endMediaTime
|
||||
}
|
||||
|
||||
if (startAt >= this.audioContext.currentTime) {
|
||||
node.start(startAt)
|
||||
}
|
||||
else {
|
||||
node.start(
|
||||
this.audioContext.currentTime,
|
||||
(this.audioContext.currentTime - startAt) * this.playbackRate,
|
||||
)
|
||||
}
|
||||
|
||||
this.queuedNodes.add(node)
|
||||
node.onended = () => this.queuedNodes.delete(node)
|
||||
}
|
||||
|
||||
if (batchDone)
|
||||
break
|
||||
|
||||
// Yield main thread after each batch so video rAF callbacks can run.
|
||||
// Without this, the audio pump's tight loop starves the render loop.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Backpressure: if we've scheduled too far ahead, wait for playback
|
||||
// to catch up before decoding more. This prevents the audio pump from
|
||||
// consuming all WebCodecs resources and starving the video decoder.
|
||||
const BUFFER_AHEAD = 1 // seconds
|
||||
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async play() {
|
||||
if (!this.paused)
|
||||
return
|
||||
|
||||
if (!this.audioContext) {
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume()
|
||||
}
|
||||
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
this.paused = false
|
||||
|
||||
const id = ++this.asyncId
|
||||
this.runIterator(id)
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.paused)
|
||||
return
|
||||
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.paused = true
|
||||
|
||||
this.stopIterator()
|
||||
this.stopQueuedNodes()
|
||||
}
|
||||
|
||||
async seek(time) {
|
||||
this.playbackTimeAtStart = Math.max(0, time)
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
|
||||
const id = ++this.asyncId
|
||||
if (!this.paused) {
|
||||
this.runIterator(id)
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume, muted) {
|
||||
this.volume = volume
|
||||
this.muted = muted
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
setPlaybackRate(rate) {
|
||||
if (rate === this.playbackRate)
|
||||
return
|
||||
|
||||
if (!this.paused) {
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
}
|
||||
|
||||
this.playbackRate = rate
|
||||
|
||||
if (!this.paused) {
|
||||
const id = ++this.asyncId
|
||||
this.runIterator(id)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.asyncId++
|
||||
this.pause()
|
||||
this.audioContext?.close()
|
||||
this.audioContext = null
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { AudioBufferSink as AudioSink, Input } from 'mediabunny'
|
||||
import type { AudioIterator } from './audio-pump'
|
||||
import type { AudioPort } from './engine-ports'
|
||||
import type EventTarget from './EventTarget'
|
||||
import type { PlaybackMedia } from './media'
|
||||
import { AudioBufferSink } from 'mediabunny'
|
||||
import { mediaTime } from './audio-clock'
|
||||
import ContextOwner from './audio-context'
|
||||
import NodeQueue from './audio-nodes'
|
||||
import AudioPump from './audio-pump'
|
||||
import AudioTask, { CANCELLED } from './audio-task'
|
||||
|
||||
export default class AudioEngine implements AudioPort {
|
||||
declare events: EventTarget
|
||||
declare input: Input | null
|
||||
declare audioSink: AudioSink | null
|
||||
declare audioIterator: AudioIterator | null
|
||||
declare audioContext: AudioContext | null
|
||||
declare gainNode: GainNode | null
|
||||
declare audioContextStartTime: number
|
||||
declare playbackTimeAtStart: number
|
||||
declare latestScheduledEndTime: number
|
||||
declare duration: number
|
||||
declare paused: boolean
|
||||
declare volume: number
|
||||
declare muted: boolean
|
||||
declare playbackRate: number
|
||||
declare asyncId: number
|
||||
declare queuedNodes: Set<AudioBufferSourceNode>
|
||||
#closed = false
|
||||
#generation = 0
|
||||
#contexts: ContextOwner
|
||||
#nodes: NodeQueue
|
||||
#pump: AudioPump
|
||||
#playTask: AudioTask | null = null
|
||||
#playing: Promise<void> | null = null
|
||||
#loading: Promise<void> | null = null
|
||||
#onError: ((error: unknown) => void) | undefined
|
||||
|
||||
constructor(events: EventTarget, onError?: (error: unknown) => void) {
|
||||
this.events = events
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.audioIterator = null
|
||||
this.audioContext = null
|
||||
this.gainNode = null
|
||||
this.audioContextStartTime = 0
|
||||
this.playbackTimeAtStart = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
this.duration = Number.NaN
|
||||
this.paused = true
|
||||
this.volume = 0.7
|
||||
this.muted = false
|
||||
this.playbackRate = 1
|
||||
this.asyncId = 0
|
||||
this.queuedNodes = new Set()
|
||||
this.#contexts = new ContextOwner(this)
|
||||
this.#nodes = new NodeQueue(this)
|
||||
this.#pump = new AudioPump(this, this.#contexts, this.#nodes)
|
||||
this.#onError = onError
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return mediaTime(this)
|
||||
}
|
||||
|
||||
ensureAudioContext(sampleRate?: number): void {
|
||||
this.#contexts.ensure(sampleRate)
|
||||
}
|
||||
|
||||
updateGain(): void {
|
||||
this.#contexts.updateGain()
|
||||
}
|
||||
|
||||
stopQueuedNodes(): void {
|
||||
this.#nodes.stop()
|
||||
}
|
||||
|
||||
async stopIterator(): Promise<void> {
|
||||
await this.#pump.stopIterator()
|
||||
}
|
||||
|
||||
handleNoAudioTrack(): void {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
cancelPending(): void {
|
||||
this.#generation++
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
finally {
|
||||
this.#loading = null
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
}
|
||||
}
|
||||
|
||||
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.cancelPending()
|
||||
const generation = this.#generation
|
||||
this.playbackTimeAtStart = 0
|
||||
this.audioContextStartTime = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
const pending = Promise.resolve().then(() => this.#load(media, generation, onMetadata))
|
||||
this.#loading = pending
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.#loading === pending)
|
||||
this.#loading = null
|
||||
}
|
||||
}
|
||||
|
||||
async #load({ input, audioTrack, duration }: PlaybackMedia, generation: number, onMetadata?: () => void): Promise<void> {
|
||||
const current = () => !this.#closed && this.#generation === generation
|
||||
if (!current())
|
||||
return
|
||||
this.input = input
|
||||
this.duration = duration
|
||||
try {
|
||||
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode()
|
||||
if (!current())
|
||||
return
|
||||
if (!decodable) {
|
||||
this.handleNoAudioTrack()
|
||||
}
|
||||
else {
|
||||
this.ensureAudioContext(audioTrack.sampleRate)
|
||||
if (!current())
|
||||
return
|
||||
this.audioSink = new AudioBufferSink(audioTrack)
|
||||
}
|
||||
if (current())
|
||||
onMetadata?.()
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runIterator(localId: number): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
await this.#pump.run(localId)
|
||||
}
|
||||
|
||||
#startPump(id: number): void {
|
||||
void this.runIterator(id).catch((error) => {
|
||||
if (this.#closed || id !== this.asyncId)
|
||||
return
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio failure cleanup:', failure) }
|
||||
try {
|
||||
if (this.#onError)
|
||||
this.#onError(error)
|
||||
else
|
||||
this.events.emit('error', error)
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio error listener:', failure) }
|
||||
})
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
if (this.#playing)
|
||||
return this.#playing
|
||||
if (!this.paused)
|
||||
return
|
||||
const task = new AudioTask()
|
||||
this.#playTask = task
|
||||
const generation = this.#generation
|
||||
const pending = this.#play(task, generation)
|
||||
this.#playing = pending
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.#playTask === task) {
|
||||
this.#playTask = null
|
||||
this.#playing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #play(task: AudioTask, generation: number): Promise<void> {
|
||||
const current = () => !this.#closed && !task.cancelled && this.#generation === generation
|
||||
try {
|
||||
const context = this.#contexts.ensure()
|
||||
if (!context || !current())
|
||||
return
|
||||
if (context.state === 'suspended') {
|
||||
const resumed = await task.wait(this.#contexts.resume(context))
|
||||
if (resumed === CANCELLED || !current())
|
||||
return
|
||||
}
|
||||
if (this.#loading) {
|
||||
const loaded = await task.wait(this.#loading)
|
||||
if (loaded === CANCELLED || !current())
|
||||
return
|
||||
}
|
||||
this.audioContextStartTime = context.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
this.paused = false
|
||||
this.#startPump(++this.asyncId)
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.paused)
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.paused = true
|
||||
this.asyncId++
|
||||
this.#playTask?.cancel()
|
||||
this.#playTask = null
|
||||
this.#playing = null
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
}
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
const playing = !this.paused
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
this.playbackTimeAtStart = Math.max(0, time)
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
const id = ++this.asyncId
|
||||
if (playing)
|
||||
this.#startPump(id)
|
||||
}
|
||||
|
||||
setVolume(volume: number, muted: boolean): void {
|
||||
this.volume = volume
|
||||
this.muted = muted
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
setPlaybackRate(rate: number): void {
|
||||
if (this.#closed || rate === this.playbackRate)
|
||||
return
|
||||
const time = this.currentTime
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
this.playbackTimeAtStart = time
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
|
||||
this.playbackRate = rate
|
||||
this.latestScheduledEndTime = time
|
||||
const id = ++this.asyncId
|
||||
if (!this.paused)
|
||||
this.#startPump(id)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.#closed = true
|
||||
this.#generation++
|
||||
const failures: unknown[] = []
|
||||
for (const release of [() => this.pause(), () => this.#contexts.destroy()]) {
|
||||
try {
|
||||
release()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.#loading = null
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,15 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
|
||||
this.events = events
|
||||
this.option = option
|
||||
this.audio = new AudioEngine(events)
|
||||
this.audio = new AudioEngine(events, (error) => {
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
catch (failure) {
|
||||
console.warn('MediaBunny audio coordinator cleanup:', failure)
|
||||
}
|
||||
this.reportError(error)
|
||||
})
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
@@ -73,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
this.ended = true
|
||||
this.paused = true
|
||||
this.#playback.ended()
|
||||
this.audio.pause()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +91,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
const id = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
this.video.cancelPending()
|
||||
this.audio.cancelPending()
|
||||
this.loadSession?.cancel()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
@@ -107,6 +117,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
const failed = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
this.video.cancelPending()
|
||||
this.audio.cancelPending()
|
||||
session.cancel()
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface AudioClock {
|
||||
audioContext: AudioContext | null
|
||||
paused: boolean
|
||||
audioContextStartTime: number
|
||||
playbackTimeAtStart: number
|
||||
playbackRate: number
|
||||
}
|
||||
|
||||
export function mediaTime(clock: AudioClock): number {
|
||||
if (clock.paused || !clock.audioContext)
|
||||
return clock.playbackTimeAtStart
|
||||
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
|
||||
}
|
||||
|
||||
export function bufferTiming(clock: AudioClock, timestamp: number, duration: number, now: number) {
|
||||
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate
|
||||
const endAt = startAt + duration / clock.playbackRate
|
||||
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
|
||||
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
interface ContextHost {
|
||||
audioContext: AudioContext | null
|
||||
gainNode: GainNode | null
|
||||
volume: number
|
||||
muted: boolean
|
||||
}
|
||||
|
||||
export default class ContextOwner {
|
||||
private closed = false
|
||||
private resumes = new WeakMap<AudioContext, Promise<void>>()
|
||||
|
||||
constructor(private host: ContextHost) {}
|
||||
|
||||
ensure(sampleRate?: number): AudioContext | null {
|
||||
if (this.closed)
|
||||
return null
|
||||
if (this.host.audioContext)
|
||||
return this.host.audioContext
|
||||
const Context = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
let context: AudioContext
|
||||
try {
|
||||
context = new Context({ sampleRate })
|
||||
}
|
||||
catch {
|
||||
context = new Context()
|
||||
}
|
||||
if (this.closed) {
|
||||
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
|
||||
return null
|
||||
}
|
||||
let gain: GainNode | null = null
|
||||
try {
|
||||
gain = context.createGain()
|
||||
gain.connect(context.destination)
|
||||
const volume = this.host.muted ? 0 : this.host.volume
|
||||
gain.gain.value = volume * volume
|
||||
if (this.closed) {
|
||||
gain.disconnect()
|
||||
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
|
||||
return null
|
||||
}
|
||||
this.host.audioContext = context
|
||||
this.host.gainNode = gain
|
||||
return context
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
gain?.disconnect()
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny gain setup cleanup:', failure) }
|
||||
void context.close().catch(failure => console.warn('MediaBunny context setup cleanup:', failure))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
updateGain(): void {
|
||||
if (!this.host.gainNode)
|
||||
return
|
||||
const volume = this.host.muted ? 0 : this.host.volume
|
||||
this.host.gainNode.gain.value = volume * volume
|
||||
}
|
||||
|
||||
resume(context: AudioContext): Promise<void> {
|
||||
const previous = this.resumes.get(context)
|
||||
if (previous)
|
||||
return previous
|
||||
const pending = context.resume()
|
||||
this.resumes.set(context, pending)
|
||||
const clear = () => {
|
||||
if (this.resumes.get(context) === pending)
|
||||
this.resumes.delete(context)
|
||||
}
|
||||
pending.then(clear, clear)
|
||||
return pending
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.closed)
|
||||
return
|
||||
this.closed = true
|
||||
const { audioContext: context, gainNode: gain } = this.host
|
||||
this.host.audioContext = null
|
||||
this.host.gainNode = null
|
||||
try {
|
||||
gain?.disconnect()
|
||||
}
|
||||
finally {
|
||||
if (context)
|
||||
void context.close().catch(error => console.warn('MediaBunny context close error:', error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { WrappedAudioBuffer } from 'mediabunny'
|
||||
import type { AudioClock } from './audio-clock'
|
||||
import { bufferTiming } from './audio-clock'
|
||||
|
||||
interface QueueHost extends AudioClock {
|
||||
gainNode: GainNode | null
|
||||
queuedNodes: Set<AudioBufferSourceNode>
|
||||
latestScheduledEndTime: number
|
||||
}
|
||||
|
||||
export default class NodeQueue {
|
||||
constructor(private host: QueueHost) {}
|
||||
|
||||
private release(node: AudioBufferSourceNode, stop: boolean): void {
|
||||
this.host.queuedNodes.delete(node)
|
||||
node.onended = null
|
||||
let failed = false
|
||||
let failure: unknown
|
||||
try {
|
||||
if (stop)
|
||||
node.stop()
|
||||
}
|
||||
catch (error) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
try {
|
||||
node.disconnect()
|
||||
}
|
||||
catch (error) {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const failures: unknown[] = []
|
||||
for (const node of [...this.host.queuedNodes]) {
|
||||
try {
|
||||
this.release(node, true)
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
schedule({ buffer, timestamp }: WrappedAudioBuffer): void {
|
||||
const context = this.host.audioContext
|
||||
const gain = this.host.gainNode
|
||||
if (!context || !gain)
|
||||
return
|
||||
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime)
|
||||
if (timing.offset !== null && timing.offset >= buffer.duration)
|
||||
return
|
||||
const node = context.createBufferSource()
|
||||
this.host.queuedNodes.add(node)
|
||||
try {
|
||||
node.buffer = buffer
|
||||
node.connect(gain)
|
||||
node.playbackRate.value = this.host.playbackRate
|
||||
node.onended = () => {
|
||||
if (!this.host.queuedNodes.has(node))
|
||||
return
|
||||
try {
|
||||
this.release(node, false)
|
||||
}
|
||||
catch (error) { console.warn('MediaBunny ended node cleanup:', error) }
|
||||
}
|
||||
if (timing.offset === null)
|
||||
node.start(timing.startAt)
|
||||
else
|
||||
node.start(timing.startAt, timing.offset)
|
||||
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime)
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
this.release(node, true)
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio node cleanup:', failure) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AudioBufferSink, WrappedAudioBuffer } from 'mediabunny'
|
||||
import type ContextOwner from './audio-context'
|
||||
import type NodeQueue from './audio-nodes'
|
||||
import type EventTarget from './EventTarget'
|
||||
import AudioTask, { CANCELLED } from './audio-task'
|
||||
import { publish } from './readiness'
|
||||
|
||||
export type AudioIterator = Pick<AsyncGenerator<WrappedAudioBuffer, void, unknown>, 'next' | 'return'>
|
||||
export interface PumpHost {
|
||||
audioSink: AudioBufferSink | null
|
||||
audioIterator: AudioIterator | null
|
||||
audioContext: AudioContext | null
|
||||
paused: boolean
|
||||
asyncId: number
|
||||
readonly currentTime: number
|
||||
latestScheduledEndTime: number
|
||||
events: EventTarget
|
||||
}
|
||||
|
||||
export default class AudioPump {
|
||||
private task: AudioTask | null = null
|
||||
private closing = new WeakMap<AudioIterator, Promise<void>>()
|
||||
|
||||
constructor(private host: PumpHost, private contexts: ContextOwner, private nodes: NodeQueue) {}
|
||||
|
||||
async stopIterator(): Promise<void> {
|
||||
const iterator = this.host.audioIterator
|
||||
this.host.audioIterator = null
|
||||
if (!iterator)
|
||||
return
|
||||
let pending = this.closing.get(iterator)
|
||||
if (!pending) {
|
||||
pending = Promise.resolve().then(() => iterator.return()).then(() => {})
|
||||
this.closing.set(iterator, pending)
|
||||
}
|
||||
await pending
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.task?.cancel()
|
||||
this.task = null
|
||||
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
|
||||
}
|
||||
|
||||
async run(id: number): Promise<void> {
|
||||
if (this.host.asyncId !== id)
|
||||
return
|
||||
this.cancel()
|
||||
const sink = this.host.audioSink
|
||||
const context = this.host.audioContext
|
||||
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
|
||||
return
|
||||
const task = new AudioTask()
|
||||
this.task = task
|
||||
let iterator: AudioIterator | null = null
|
||||
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator)
|
||||
try {
|
||||
iterator = sink.buffers(this.host.currentTime)
|
||||
this.host.audioIterator = iterator
|
||||
while (current()) {
|
||||
const batch: WrappedAudioBuffer[] = []
|
||||
let done = false
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const result = await task.wait(iterator.next())
|
||||
if (result === CANCELLED || !current())
|
||||
return
|
||||
if (result.done) {
|
||||
done = true
|
||||
break
|
||||
}
|
||||
batch.push(result.value)
|
||||
}
|
||||
if (batch.length && context.state === 'suspended') {
|
||||
const resumed = await task.wait(this.contexts.resume(context))
|
||||
if (resumed === CANCELLED || !current())
|
||||
return
|
||||
publish(this.host.events, current, ['canplay', 'playing'])
|
||||
}
|
||||
for (const buffer of batch) {
|
||||
if (!current())
|
||||
return
|
||||
this.nodes.schedule(buffer)
|
||||
}
|
||||
if (done)
|
||||
return
|
||||
if (!(await task.delay(0)) || !current())
|
||||
return
|
||||
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
|
||||
if (!(await task.delay(50)) || !current())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
task.cancel()
|
||||
if (this.task === task) {
|
||||
this.task = null
|
||||
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const CANCELLED = Symbol('cancelled')
|
||||
|
||||
export default class AudioTask {
|
||||
cancelled = false
|
||||
private waits = new Set<() => void>()
|
||||
private timers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>()
|
||||
|
||||
wait<T>(pending: Promise<T>): Promise<T | typeof CANCELLED> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => resolve(CANCELLED)
|
||||
if (this.cancelled)
|
||||
cancel()
|
||||
else
|
||||
this.waits.add(cancel)
|
||||
pending.then((value) => {
|
||||
this.waits.delete(cancel)
|
||||
resolve(value)
|
||||
}, (error) => {
|
||||
this.waits.delete(cancel)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
delay(ms: number): Promise<boolean> {
|
||||
if (this.cancelled)
|
||||
return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer)
|
||||
resolve(!this.cancelled)
|
||||
}, ms)
|
||||
this.timers.set(timer, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.cancelled)
|
||||
return
|
||||
this.cancelled = true
|
||||
for (const cancel of this.waits)
|
||||
cancel()
|
||||
this.waits.clear()
|
||||
for (const [timer, resolve] of this.timers) {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
}
|
||||
this.timers.clear()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type EventTarget from './EventTarget'
|
||||
import type { PlaybackMedia } from './media'
|
||||
|
||||
export interface AudioPort {
|
||||
cancelPending: () => void
|
||||
readonly currentTime: number
|
||||
duration: number
|
||||
paused: boolean
|
||||
|
||||
@@ -28,7 +28,6 @@ export default class Playback {
|
||||
private wanted = false
|
||||
private playing: Operation | null = null
|
||||
private seeking: Operation | null = null
|
||||
private audioStart: Promise<void> | null = null
|
||||
|
||||
constructor(private host: PlaybackHost) {}
|
||||
|
||||
@@ -103,19 +102,6 @@ export default class Playback {
|
||||
return operation.promise
|
||||
}
|
||||
|
||||
private async startAudio(): Promise<void> {
|
||||
if (!this.audioStart)
|
||||
this.audioStart = this.host.audio.play()
|
||||
const pending = this.audioStart
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.audioStart === pending)
|
||||
this.audioStart = null
|
||||
}
|
||||
}
|
||||
|
||||
private async runPlay(operation: Operation): Promise<void> {
|
||||
const current = () => this.current(operation, 'playing') && this.wanted
|
||||
try {
|
||||
@@ -127,7 +113,7 @@ export default class Playback {
|
||||
if (!current())
|
||||
return
|
||||
this.host.paused = false
|
||||
await this.startAudio()
|
||||
await this.host.audio.play()
|
||||
if (!current()) {
|
||||
if (!this.wanted || this.host.destroyed)
|
||||
this.host.audio.pause()
|
||||
|
||||
Reference in new issue
Block a user