Skip to content

Commit

Permalink
reimplement replay gain, fade, volume using web audio api
Browse files Browse the repository at this point in the history
  • Loading branch information
tamland committed Jan 20, 2025
1 parent e483434 commit 41d2a78
Showing 1 changed file with 89 additions and 108 deletions.
197 changes: 89 additions & 108 deletions src/player/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ type ReplayGain = {
}

export class AudioController {
private audio = new Audio()
private handle = -1
private volume = 1.0
private fadeDuration = 200
private fadeDuration = 0.4

private buffer = new Audio()
private statsListener : any = null
private replayGainMode = ReplayGainMode.None
private replayGain: ReplayGain | null = null

private context = new AudioContext()
private pipeline = creatPipeline(this.context)

ontimeupdate: (value: number) => void = () => { /* do nothing */ }
ondurationchange: (value: number) => void = () => { /* do nothing */ }
onpause: () => void = () => { /* do nothing */ }
Expand All @@ -32,78 +33,76 @@ export class AudioController {
onerror: (err: MediaError | null) => void = () => { /* do nothing */ }

currentTime() {
return this.audio.currentTime
return this.pipeline.audio.currentTime
}

duration() {
return this.audio.duration
return this.pipeline.audio.duration
}

setBuffer(url: string) {
this.buffer.src = url
}

setVolume(value: number) {
this.cancelFade()
this.volume = value
this.audio.volume = value * this.replayGainFactor()
this.pipeline.volumeNode.gain.value = value
}

setReplayGainMode(value: ReplayGainMode) {
this.replayGainMode = value
this.setVolume(this.volume)
this.pipeline.replayGainNode.gain.value = this.replayGainFactor()
console.log('Set replay gain: ' + this.replayGainFactor())
}

setPlaybackRate(value: number) {
this.audio.playbackRate = value
this.pipeline.audio.playbackRate = value
}

async pause() {
await this.fadeOut()
this.audio.pause()
await this.fadeOut(0.1)
this.pipeline.audio.pause()
}

async resume() {
this.audio.volume = 0.0
await this.audio.play()
this.fadeIn()
await this.pipeline.audio.play()
await this.fadeIn()
}

async seek(value: number) {
await this.fadeOut(this.fadeDuration / 2.0)
this.audio.volume = 0.0
this.audio.currentTime = value
this.pipeline.audio.currentTime = value
await this.fadeIn(this.fadeDuration / 2.0)
}

async changeTrack(options: { url?: string, paused?: boolean, replayGain?: ReplayGain, isStream?: boolean, playbackRate?: number }) {
this.replayGain = options.replayGain || null

if (this.audio) {
this.cancelFade()
endPlayback(this.audio, this.fadeDuration)
if (this.pipeline.audio) {
endPlayback(this.context, this.pipeline, this.fadeDuration)
}
this.audio = new Audio(options.url)
this.audio.onerror = () => {
this.onerror(this.audio.error)

this.pipeline = creatPipeline(this.context, options.url)
this.pipeline.replayGainNode.gain.value = this.replayGainFactor()

this.pipeline.audio.onerror = () => {
this.onerror(this.pipeline.audio.error)
}
this.audio.onended = () => {
this.pipeline.audio.onended = () => {
this.onended()
}
this.audio.ontimeupdate = () => {
this.ontimeupdate(this.audio.currentTime)
this.pipeline.audio.ontimeupdate = () => {
this.ontimeupdate(this.pipeline.audio.currentTime)
}
this.audio.ondurationchange = () => {
this.ondurationchange(this.audio.duration)
this.pipeline.audio.ondurationchange = () => {
this.ondurationchange(this.pipeline.audio.duration)
}
this.audio.onpause = () => {
this.pipeline.audio.onpause = () => {
this.onpause()
}
this.ondurationchange(this.audio.duration)
this.ontimeupdate(this.audio.currentTime)
this.ondurationchange(this.pipeline.audio.duration)
this.ontimeupdate(this.pipeline.audio.currentTime)
this.onstreamtitlechange(null)
this.audio.volume = 0.0
this.audio.playbackRate = options.playbackRate ?? 1.0
this.pipeline.audio.playbackRate = options.playbackRate ?? 1.0

this.statsListener?.stop()
if (options.isStream) {
Expand All @@ -124,59 +123,30 @@ export class AudioController {

if (options.paused !== true) {
try {
await this.audio.play()
await this.pipeline.audio.play()
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.warn(error)
return
}
throw error
}
this.fadeIn()
await this.fadeIn()
}
}

private cancelFade() {
clearTimeout(this.handle)
private async fadeIn(duration: number = this.fadeDuration) {
this.pipeline.fadeNode.gain.cancelScheduledValues(0)
this.pipeline.fadeNode.gain.linearRampToValueAtTime(0, this.context.currentTime)
this.pipeline.fadeNode.gain.linearRampToValueAtTime(1, this.context.currentTime + duration)
await sleep(duration * 1000)
}

private fadeIn(duration: number = this.fadeDuration) {
this.fadeFromTo(0.0, this.volume, duration).then()
}

private fadeOut(duration: number = this.fadeDuration) {
return this.fadeFromTo(this.volume, 0.0, duration)
}

private fadeFromTo(from: number, to: number, duration: number) {
const replayGainFactor = this.replayGainFactor()
from *= replayGainFactor
to *= replayGainFactor

console.info(`AudioController: start fade (${from}, ${to}, ${duration})`)
const startTime = Date.now()

const step = (to - from) / duration
if (duration <= 0.0) {
this.audio.volume = to
}
clearTimeout(this.handle)
return new Promise<void>((resolve) => {
const run = () => {
if (this.audio.volume === to) {
console.info(
'AudioController: fade result. ' +
`duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` +
`volume: ${this.audio.volume}`)
resolve()
return
}
const elapsed = Date.now() - startTime
this.audio.volume = clamp(0.0, Math.max(from, to), from + (elapsed * step))
this.handle = setTimeout(run, 10)
}
run()
})
private async fadeOut(duration: number = this.fadeDuration) {
this.pipeline.fadeNode.gain.cancelScheduledValues(0)
this.pipeline.fadeNode.gain.linearRampToValueAtTime(1, this.context.currentTime)
this.pipeline.fadeNode.gain.linearRampToValueAtTime(0, this.context.currentTime + duration)
await sleep(duration * 1000)
}

private replayGainFactor(): number {
Expand All @@ -202,48 +172,59 @@ export class AudioController {
const gainFactor = Math.pow(10, (gain + preAmp) / 20)
const peakFactor = 1 / peak
const factor = Math.min(gainFactor, peakFactor)

console.info('AudioController: calculated ReplayGain factor', factor)

return factor
}
}

function endPlayback(audio: HTMLAudioElement, duration: number) {
async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) {
if (duration <= 0.0) {
audio.volume = to
return audio
}
const startTime = Date.now()
const step = (to - from) / duration
while (audio.volume !== to) {
const elapsed = Date.now() - startTime
audio.volume = clamp(0.0, 1.0, from + (elapsed * step))
await sleep(10)
}
return audio
function creatPipeline(context: AudioContext, url?: string) {
const audio = new Audio(url)
audio.crossOrigin = 'anonymous'
const sourceNode = context.createMediaElementSource(audio)

const volumeNode = context.createGain()
const replayGainNode = context.createGain()

const fadeNode = context.createGain()
fadeNode.gain.value = 0

sourceNode
.connect(volumeNode)
.connect(replayGainNode)
.connect(fadeNode)
.connect(context.destination)

function disconnect() {
audio.pause()
sourceNode.disconnect()
volumeNode.disconnect()
replayGainNode.disconnect()
fadeNode.disconnect()
}
console.info(`AudioController: ending payback for ${audio}`)
audio.ontimeupdate = null
audio.ondurationchange = null
audio.onpause = null
audio.onerror = null
audio.onended = null
audio.onloadedmetadata = null

return { audio, volumeNode, replayGainNode, fadeNode, disconnect }
}

function endPlayback(context: AudioContext, pipeline: ReturnType<typeof creatPipeline>, duration: number) {
console.info(`AudioController: ending payback for ${pipeline.audio}`)
pipeline.audio.ontimeupdate = null
pipeline.audio.ondurationchange = null
pipeline.audio.onpause = null
pipeline.audio.onerror = null
pipeline.audio.onended = null
pipeline.audio.onloadedmetadata = null

duration = 3.0 // Testing
// pipeline.fadeNode.gain.cancelScheduledValues(0)
pipeline.fadeNode.gain.linearRampToValueAtTime(0, context.currentTime + duration)

const startTime = Date.now()
fade(audio, audio.volume, 0.0, duration)
.catch((err) => console.warn('Error during fade out: ' + err.stack))
.finally(() => {
audio.pause()
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
})
setTimeout(() => {
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
pipeline.disconnect()
}, duration * 1000)
}

function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

function clamp(min: number, max: number, value: number) {
return Math.max(min, Math.min(value, max))
}

0 comments on commit 41d2a78

Please sign in to comment.