refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:21:25 +08:00
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()