mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-12 23:36:15 -08:00
- Deleted ARCHITECTURE.md file to streamline project documentation. - Refactored AudioEngine, EventTarget, MediaBunnyEngine, VideoEngine, VideoShim, and index.js for improved readability by adding consistent line breaks and spacing. - Enhanced code structure by ensuring single-responsibility principles and clearer flow in methods. - Updated TypeScript definitions for better clarity on options available in the artplayer-proxy-mediabunny package.
306 lines
6.7 KiB
JavaScript
306 lines
6.7 KiB
JavaScript
/**
|
|
* Audio Engine for MediaBunny
|
|
* Handles audio playback using Web Audio API
|
|
*/
|
|
import {
|
|
ALL_FORMATS,
|
|
AudioBufferSink,
|
|
BlobSource,
|
|
Input,
|
|
ReadableStreamSource,
|
|
UrlSource,
|
|
} 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
|
|
)
|
|
}
|
|
|
|
normalizeSource(src) {
|
|
if (typeof src === 'string')
|
|
return new UrlSource(src)
|
|
if (src instanceof Blob)
|
|
return new BlobSource(src)
|
|
if (typeof ReadableStream !== 'undefined' && src instanceof ReadableStream) {
|
|
return new ReadableStreamSource(src)
|
|
}
|
|
return src
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async load(src, onMetadata) {
|
|
const id = ++this.asyncId
|
|
|
|
await this.stopIterator()
|
|
this.stopQueuedNodes()
|
|
|
|
this.paused = true
|
|
this.playbackTimeAtStart = 0
|
|
this.audioContextStartTime = 0
|
|
|
|
const source = this.normalizeSource(src)
|
|
if (!source)
|
|
return
|
|
|
|
this.input = new Input({
|
|
source,
|
|
formats: ALL_FORMATS,
|
|
})
|
|
|
|
this.duration = await this.input.computeDuration()
|
|
if (id !== this.asyncId)
|
|
return
|
|
|
|
const audioTrack = await this.input.getPrimaryAudioTrack()
|
|
if (!audioTrack) {
|
|
this.audioSink = null
|
|
this.ensureAudioContext()
|
|
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)
|
|
|
|
while (true) {
|
|
if (localId !== this.asyncId || this.paused)
|
|
return
|
|
|
|
const nextPromise = this.audioIterator.next()
|
|
|
|
// Monitor for buffer starvation
|
|
const checkStarvation = setInterval(() => {
|
|
if (localId !== this.asyncId || this.paused) {
|
|
clearInterval(checkStarvation)
|
|
return
|
|
}
|
|
|
|
if (
|
|
this.audioContext.state === 'running'
|
|
&& this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
|
|
) {
|
|
this.audioContext.suspend()
|
|
this.events.emit('waiting')
|
|
}
|
|
}, 50)
|
|
|
|
let result
|
|
try {
|
|
result = await nextPromise
|
|
}
|
|
catch (e) {
|
|
console.error('Audio iterator error:', e)
|
|
break
|
|
}
|
|
finally {
|
|
clearInterval(checkStarvation)
|
|
}
|
|
|
|
if (localId !== this.asyncId || this.paused)
|
|
return
|
|
|
|
// Resume if was suspended
|
|
if (this.audioContext.state === 'suspended') {
|
|
await this.audioContext.resume()
|
|
this.events.emit('canplay')
|
|
this.events.emit('playing')
|
|
}
|
|
|
|
if (result.done)
|
|
break
|
|
|
|
const { buffer, timestamp } = result.value
|
|
|
|
// Schedule audio buffer
|
|
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
|
|
|
|
if (endAt > this.latestScheduledEndTime) {
|
|
this.latestScheduledEndTime = endAt
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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.audioContextStartTime
|
|
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.audioContextStartTime
|
|
|
|
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
|
|
}
|
|
}
|