Skip to content

Commit 41d2a78

Browse files
committed
reimplement replay gain, fade, volume using web audio api
1 parent e483434 commit 41d2a78

File tree

1 file changed

+89
-108
lines changed

1 file changed

+89
-108
lines changed

src/player/audio.ts

+89-108
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ type ReplayGain = {
1515
}
1616

1717
export class AudioController {
18-
private audio = new Audio()
19-
private handle = -1
20-
private volume = 1.0
21-
private fadeDuration = 200
18+
private fadeDuration = 0.4
19+
2220
private buffer = new Audio()
2321
private statsListener : any = null
2422
private replayGainMode = ReplayGainMode.None
2523
private replayGain: ReplayGain | null = null
2624

25+
private context = new AudioContext()
26+
private pipeline = creatPipeline(this.context)
27+
2728
ontimeupdate: (value: number) => void = () => { /* do nothing */ }
2829
ondurationchange: (value: number) => void = () => { /* do nothing */ }
2930
onpause: () => void = () => { /* do nothing */ }
@@ -32,78 +33,76 @@ export class AudioController {
3233
onerror: (err: MediaError | null) => void = () => { /* do nothing */ }
3334

3435
currentTime() {
35-
return this.audio.currentTime
36+
return this.pipeline.audio.currentTime
3637
}
3738

3839
duration() {
39-
return this.audio.duration
40+
return this.pipeline.audio.duration
4041
}
4142

4243
setBuffer(url: string) {
4344
this.buffer.src = url
4445
}
4546

4647
setVolume(value: number) {
47-
this.cancelFade()
48-
this.volume = value
49-
this.audio.volume = value * this.replayGainFactor()
48+
this.pipeline.volumeNode.gain.value = value
5049
}
5150

5251
setReplayGainMode(value: ReplayGainMode) {
5352
this.replayGainMode = value
54-
this.setVolume(this.volume)
53+
this.pipeline.replayGainNode.gain.value = this.replayGainFactor()
54+
console.log('Set replay gain: ' + this.replayGainFactor())
5555
}
5656

5757
setPlaybackRate(value: number) {
58-
this.audio.playbackRate = value
58+
this.pipeline.audio.playbackRate = value
5959
}
6060

6161
async pause() {
62-
await this.fadeOut()
63-
this.audio.pause()
62+
await this.fadeOut(0.1)
63+
this.pipeline.audio.pause()
6464
}
6565

6666
async resume() {
67-
this.audio.volume = 0.0
68-
await this.audio.play()
69-
this.fadeIn()
67+
await this.pipeline.audio.play()
68+
await this.fadeIn()
7069
}
7170

7271
async seek(value: number) {
7372
await this.fadeOut(this.fadeDuration / 2.0)
74-
this.audio.volume = 0.0
75-
this.audio.currentTime = value
73+
this.pipeline.audio.currentTime = value
7674
await this.fadeIn(this.fadeDuration / 2.0)
7775
}
7876

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

82-
if (this.audio) {
83-
this.cancelFade()
84-
endPlayback(this.audio, this.fadeDuration)
80+
if (this.pipeline.audio) {
81+
endPlayback(this.context, this.pipeline, this.fadeDuration)
8582
}
86-
this.audio = new Audio(options.url)
87-
this.audio.onerror = () => {
88-
this.onerror(this.audio.error)
83+
84+
this.pipeline = creatPipeline(this.context, options.url)
85+
this.pipeline.replayGainNode.gain.value = this.replayGainFactor()
86+
87+
this.pipeline.audio.onerror = () => {
88+
this.onerror(this.pipeline.audio.error)
8989
}
90-
this.audio.onended = () => {
90+
this.pipeline.audio.onended = () => {
9191
this.onended()
9292
}
93-
this.audio.ontimeupdate = () => {
94-
this.ontimeupdate(this.audio.currentTime)
93+
this.pipeline.audio.ontimeupdate = () => {
94+
this.ontimeupdate(this.pipeline.audio.currentTime)
9595
}
96-
this.audio.ondurationchange = () => {
97-
this.ondurationchange(this.audio.duration)
96+
this.pipeline.audio.ondurationchange = () => {
97+
this.ondurationchange(this.pipeline.audio.duration)
9898
}
99-
this.audio.onpause = () => {
99+
this.pipeline.audio.onpause = () => {
100100
this.onpause()
101101
}
102-
this.ondurationchange(this.audio.duration)
103-
this.ontimeupdate(this.audio.currentTime)
102+
this.ondurationchange(this.pipeline.audio.duration)
103+
this.ontimeupdate(this.pipeline.audio.currentTime)
104104
this.onstreamtitlechange(null)
105-
this.audio.volume = 0.0
106-
this.audio.playbackRate = options.playbackRate ?? 1.0
105+
this.pipeline.audio.playbackRate = options.playbackRate ?? 1.0
107106

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

125124
if (options.paused !== true) {
126125
try {
127-
await this.audio.play()
126+
await this.pipeline.audio.play()
128127
} catch (error) {
129128
if (error instanceof Error && error.name === 'AbortError') {
130129
console.warn(error)
131130
return
132131
}
133132
throw error
134133
}
135-
this.fadeIn()
134+
await this.fadeIn()
136135
}
137136
}
138137

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

143-
private fadeIn(duration: number = this.fadeDuration) {
144-
this.fadeFromTo(0.0, this.volume, duration).then()
145-
}
146-
147-
private fadeOut(duration: number = this.fadeDuration) {
148-
return this.fadeFromTo(this.volume, 0.0, duration)
149-
}
150-
151-
private fadeFromTo(from: number, to: number, duration: number) {
152-
const replayGainFactor = this.replayGainFactor()
153-
from *= replayGainFactor
154-
to *= replayGainFactor
155-
156-
console.info(`AudioController: start fade (${from}, ${to}, ${duration})`)
157-
const startTime = Date.now()
158-
159-
const step = (to - from) / duration
160-
if (duration <= 0.0) {
161-
this.audio.volume = to
162-
}
163-
clearTimeout(this.handle)
164-
return new Promise<void>((resolve) => {
165-
const run = () => {
166-
if (this.audio.volume === to) {
167-
console.info(
168-
'AudioController: fade result. ' +
169-
`duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` +
170-
`volume: ${this.audio.volume}`)
171-
resolve()
172-
return
173-
}
174-
const elapsed = Date.now() - startTime
175-
this.audio.volume = clamp(0.0, Math.max(from, to), from + (elapsed * step))
176-
this.handle = setTimeout(run, 10)
177-
}
178-
run()
179-
})
145+
private async fadeOut(duration: number = this.fadeDuration) {
146+
this.pipeline.fadeNode.gain.cancelScheduledValues(0)
147+
this.pipeline.fadeNode.gain.linearRampToValueAtTime(1, this.context.currentTime)
148+
this.pipeline.fadeNode.gain.linearRampToValueAtTime(0, this.context.currentTime + duration)
149+
await sleep(duration * 1000)
180150
}
181151

182152
private replayGainFactor(): number {
@@ -202,48 +172,59 @@ export class AudioController {
202172
const gainFactor = Math.pow(10, (gain + preAmp) / 20)
203173
const peakFactor = 1 / peak
204174
const factor = Math.min(gainFactor, peakFactor)
205-
206175
console.info('AudioController: calculated ReplayGain factor', factor)
207-
208176
return factor
209177
}
210178
}
211179

212-
function endPlayback(audio: HTMLAudioElement, duration: number) {
213-
async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) {
214-
if (duration <= 0.0) {
215-
audio.volume = to
216-
return audio
217-
}
218-
const startTime = Date.now()
219-
const step = (to - from) / duration
220-
while (audio.volume !== to) {
221-
const elapsed = Date.now() - startTime
222-
audio.volume = clamp(0.0, 1.0, from + (elapsed * step))
223-
await sleep(10)
224-
}
225-
return audio
180+
function creatPipeline(context: AudioContext, url?: string) {
181+
const audio = new Audio(url)
182+
audio.crossOrigin = 'anonymous'
183+
const sourceNode = context.createMediaElementSource(audio)
184+
185+
const volumeNode = context.createGain()
186+
const replayGainNode = context.createGain()
187+
188+
const fadeNode = context.createGain()
189+
fadeNode.gain.value = 0
190+
191+
sourceNode
192+
.connect(volumeNode)
193+
.connect(replayGainNode)
194+
.connect(fadeNode)
195+
.connect(context.destination)
196+
197+
function disconnect() {
198+
audio.pause()
199+
sourceNode.disconnect()
200+
volumeNode.disconnect()
201+
replayGainNode.disconnect()
202+
fadeNode.disconnect()
226203
}
227-
console.info(`AudioController: ending payback for ${audio}`)
228-
audio.ontimeupdate = null
229-
audio.ondurationchange = null
230-
audio.onpause = null
231-
audio.onerror = null
232-
audio.onended = null
233-
audio.onloadedmetadata = null
204+
205+
return { audio, volumeNode, replayGainNode, fadeNode, disconnect }
206+
}
207+
208+
function endPlayback(context: AudioContext, pipeline: ReturnType<typeof creatPipeline>, duration: number) {
209+
console.info(`AudioController: ending payback for ${pipeline.audio}`)
210+
pipeline.audio.ontimeupdate = null
211+
pipeline.audio.ondurationchange = null
212+
pipeline.audio.onpause = null
213+
pipeline.audio.onerror = null
214+
pipeline.audio.onended = null
215+
pipeline.audio.onloadedmetadata = null
216+
217+
duration = 3.0 // Testing
218+
// pipeline.fadeNode.gain.cancelScheduledValues(0)
219+
pipeline.fadeNode.gain.linearRampToValueAtTime(0, context.currentTime + duration)
220+
234221
const startTime = Date.now()
235-
fade(audio, audio.volume, 0.0, duration)
236-
.catch((err) => console.warn('Error during fade out: ' + err.stack))
237-
.finally(() => {
238-
audio.pause()
239-
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
240-
})
222+
setTimeout(() => {
223+
console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`)
224+
pipeline.disconnect()
225+
}, duration * 1000)
241226
}
242227

243228
function sleep(ms: number) {
244229
return new Promise(resolve => setTimeout(resolve, ms))
245230
}
246-
247-
function clamp(min: number, max: number, value: number) {
248-
return Math.max(min, Math.min(value, max))
249-
}

0 commit comments

Comments
 (0)