Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@embedpdf/snippet": "^2.8.0",
"@github/webauthn-json": "^2.1.1",
"@hope-ui/solid": "0.6.7",
"@mediabunny/ac3": "^1.39.2",
"@monaco-editor/loader": "1.7.0",
"@ruffle-rs/ruffle": "0.2.0-nightly.2026.3.9",
"@solid-primitives/i18n": "^2.2.1",
Expand Down Expand Up @@ -98,6 +99,7 @@
"libheif-js": "^1.19.8",
"lightgallery": "^2.9.0",
"mark.js": "^8.11.1",
"mediabunny": "^1.39.2",
"mitt": "^3.0.1",
"monaco-editor": "0.55.1",
"mpegts.js": "^1.8.0",
Expand Down
35 changes: 35 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

291 changes: 291 additions & 0 deletions src/components/artplayer-proxy-mediabunny/AudioEngine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
/**
* 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
}
}
Loading