1
0
Fork 0
muzika-gromche/Frontend/src/audio/AudioEngine.ts

555 lines
13 KiB
TypeScript

/*
AudioEngine.ts
A small singleton wrapper around the WebAudio AudioContext that:
- lazily creates the AudioContext
- provides fetch/decode + simple caching
- schedules intro and loop buffers to play seamlessly
- exposes play/pause/stop and a volume control via a master GainNode
- provides a short fade-in/out GainNode to avoid clicks (few ms)
- exposes getPosition() to read current playback time relative to intro start
*/
import type { ConfigurableWindow } from '@vueuse/core'
import type { MaybeRefOrGetter, Ref } from 'vue'
import type { AudioTrack } from '@/lib/AudioTrack'
import type { Seconds } from '@/lib/units'
import {
tryOnScopeDispose,
useRafFn,
useThrottleFn,
watchImmediate,
} from '@vueuse/core'
import {
shallowRef,
toValue,
watch,
} from 'vue'
import { useWrapTime, wrapTimeFn } from '@/lib/AudioTrack'
export const VOLUME_MAX: number = 1.5
interface PlayerHandle {
/**
* The `stop()` method schedules a sound to cease playback at the specified time.
*/
stop: (when?: Seconds) => void
}
interface AudioTrackBuffersHandle extends PlayerHandle {
/**
* Time in AudioContext coordinate system of a moment which lines up with the start of the intro audio buffer.
* If the startPosition was greater than zero, this time is already in the past when the function returns.
*/
readonly introStartTime: Seconds
}
/**
* Start playing intro + loop buffers at given position.
*
* @returns Handle with introStartTime and stop() method.
*/
function playAudioTrackBuffers(
audioCtx: AudioContext,
destinationNode: AudioNode,
audioTrack: AudioTrack,
/**
* Position in seconds from the start of the intro
*/
startPosition: Seconds = 0,
): AudioTrackBuffersHandle {
const now = audioCtx.currentTime
const introBuffer = audioTrack.loadedIntro!
const loopBuffer = audioTrack.loadedLoop!
const introDuration = introBuffer.duration
const loopDuration = loopBuffer.duration
const wrapper = wrapTimeFn(audioTrack)
startPosition = wrapper(startPosition)
let currentIntro: AudioBufferSourceNode | null
let currentLoop: AudioBufferSourceNode | null
let introStartTime: Seconds
// figure out where to start
if (startPosition < introDuration) {
// start intro with offset, schedule loop after remaining intro time
const introOffset = startPosition
const timeUntilLoop = introDuration - introOffset
const introNode = audioCtx.createBufferSource()
introNode.buffer = introBuffer
introNode.connect(destinationNode)
introNode.start(now, introOffset)
const loopNode = audioCtx.createBufferSource()
loopNode.buffer = loopBuffer
loopNode.loop = true
loopNode.connect(destinationNode)
loopNode.start(now + timeUntilLoop, 0)
currentIntro = introNode
currentLoop = loopNode
introStartTime = now - startPosition
}
else {
// start directly in loop with proper offset into loop
const loopOffset = (startPosition - introDuration) % loopDuration
const loopNode = audioCtx.createBufferSource()
loopNode.buffer = loopBuffer
loopNode.loop = true
loopNode.connect(destinationNode)
loopNode.start(now, loopOffset)
currentIntro = null
currentLoop = loopNode
// Note: using wrapping loop breaks logical position when starting playback from the second loop repetition onward.
// introStartTime = now - introDuration - loopOffset;
introStartTime = now - startPosition
}
function stop(when?: Seconds) {
try {
currentIntro?.stop(when)
}
catch {
/* ignore */
}
try {
currentLoop?.stop(when)
}
catch {
/* ignore */
}
currentIntro = null
currentLoop = null
}
return { introStartTime, stop }
}
interface PlayWithFadeInOut<T extends PlayerHandle> extends PlayerHandle {
playerResult: Omit<T, 'stop'>
}
/**
* 25 ms for fade-in/fade-out
*/
const DEFAULT_FADE_DURATION = 0.025
/**
* Wrap the given player function with a Gain node. Applies fade in effect on start and fade out on stop.
*
* @returns Handle with introStartTime and stop() method.
*/
function playWithFadeInOut<T extends PlayerHandle>(
audioCtx: AudioContext,
destinationNode: AudioNode,
player: (destinationNode: AudioNode) => T,
/**
* Duration of fade in/out in seconds. Fade out extends past the stop() call.
*/
fadeDuration: Seconds = DEFAULT_FADE_DURATION,
): PlayWithFadeInOut<T> {
const GAIN_MIN = 0.0001
const GAIN_MAX = 1.0
const fadeGain = audioCtx.createGain()
fadeGain.connect(destinationNode)
fadeGain.gain.value = GAIN_MIN
const playerHandle = player(fadeGain)
// fade in
const now = audioCtx.currentTime
const fadeEnd = now + fadeDuration
fadeGain.gain.setValueAtTime(GAIN_MIN, now)
fadeGain.gain.linearRampToValueAtTime(GAIN_MAX, fadeEnd)
// TODO: setTimeout to actually stop after `when`?
function stop(_when?: Seconds) {
// fade out
const now = audioCtx.currentTime
const fadeEnd = now + fadeDuration
fadeGain.gain.cancelScheduledValues(now)
fadeGain.gain.setValueAtTime(GAIN_MAX, now)
fadeGain.gain.linearRampToValueAtTime(GAIN_MIN, fadeEnd)
playerHandle.stop(fadeEnd)
}
return { playerResult: playerHandle, stop }
}
/**
* Properties relates to the state of playback.
*/
export interface PlaybackState {
/**
* Readonly reference to whether audio is currently playing.
*/
readonly isPlaying: Readonly<Ref<boolean>>
/**
* Readonly reference to the last remembered start-of-playback position.
*
* Will only update if stop(rememberPosition=true) or seek() is called.
*/
readonly startPosition: Readonly<Ref<Seconds>>
/**
* Returns current playback position in seconds based on AudioContext time.
*
* Hook it up to requestAnimationFrame while isPlaying is true for live updates.
*/
getCurrentPosition: () => Seconds
}
export interface StopOptions {
/**
* If true, update remembered playback position to current position, otherwise revert to last remembered one.
*
* Defaults to false.
*/
rememberPosition?: boolean
}
export interface SeekOptions {
/**
* If scrub is requested, plays a short sample at that position.
*
* Defaults to false.
*/
scrub?: boolean
// TODO: optionally keep playing after seeking?
}
/**
* Player controls and properties relates to the state of playback.
*/
export interface PlayerControls {
/**
* Start playing audio buffers from the last remembered position.
*/
play: () => void
/**
* Stop playing audio buffers.
*
* If rememberPosition is true, update remembered playback position, otherwise revert to the last remembered one.
*/
stop: (options?: StopOptions) => void
/**
* Seek to given position in seconds.
*
* - Stop the playback.
* - If scrub is requested, plays a short sample at that position.
*/
seek: (position: Seconds, options?: SeekOptions) => void
/**
* Properties relates to the state of playback.
*/
readonly playback: PlaybackState
}
interface ReusableAudioBuffersTrackPlayer extends PlayerControls {
}
function reusableAudioBuffersTrackPlayer(
audioCtx: AudioContext,
destinationNode: AudioNode,
audioTrack: AudioTrack,
): ReusableAudioBuffersTrackPlayer {
let currentHandle: PlayWithFadeInOut<AudioTrackBuffersHandle> | null = null
const isPlaying = shallowRef(false)
const wrapper = wrapTimeFn(audioTrack)
const startPosition = useWrapTime(audioTrack, 0)
function play() {
if (currentHandle) {
return
}
currentHandle = playWithFadeInOut(
audioCtx,
destinationNode,
destinationNode =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
audioTrack,
startPosition.value,
),
)
isPlaying.value = true
}
function stop(options?: { rememberPosition?: boolean }) {
const {
rememberPosition = false,
} = options ?? {}
if (currentHandle) {
isPlaying.value = false
if (rememberPosition) {
startPosition.value = getCurrentPosition()
}
// stop and discard current handle
currentHandle.stop()
currentHandle = null
}
}
// Scrub is subject to debouncing/throttling, so it doesn't start
// playing samples too often before previous ones could stop.
const doThrottledScrub = useThrottleFn(() => {
// play a short sample at the seeked position
const scrubHandle = playWithFadeInOut(
audioCtx,
destinationNode,
destinationNode =>
playAudioTrackBuffers(
audioCtx,
destinationNode,
audioTrack,
startPosition.value,
),
0.01, // short fade of 10 ms
)
setTimeout(() => {
scrubHandle.stop(0.01)
}, 80) // stop after N ms
}, 80)
function seek(seekPosition: Seconds, options?: SeekOptions) {
const {
scrub = false,
} = options ?? {}
stop({ rememberPosition: false })
startPosition.value = seekPosition
if (scrub) {
doThrottledScrub()
}
}
function getCurrentPosition(): Seconds {
if (!currentHandle) {
return startPosition.value
}
const elapsed = audioCtx.currentTime
- currentHandle.playerResult.introStartTime
return wrapper(elapsed)
}
return {
play,
stop,
seek,
playback: {
isPlaying,
startPosition,
getCurrentPosition,
},
}
}
interface LivePlaybackPositionOptions extends ConfigurableWindow {
}
interface LivePlaybackPositionReturn {
stop: () => void
position: Readonly<Ref<Seconds>>
}
export function useLivePlaybackPosition(
playback: MaybeRefOrGetter<PlaybackState | null>,
options?: LivePlaybackPositionOptions,
): LivePlaybackPositionReturn {
const cleanups: (() => void)[] = []
const cleanup = () => {
cleanups.forEach(fn => fn())
cleanups.length = 0
}
const getPosition = () => {
return toValue(playback)?.getCurrentPosition() ?? 0
}
const position = shallowRef<Seconds>(getPosition())
const updatePosition = () => {
position.value = getPosition()
}
const raf = useRafFn(() => {
updatePosition()
}, {
...options,
immediate: false,
once: false,
})
const stopWatch = watchImmediate(() => [
toValue(playback),
], ([playback]) => {
cleanup()
updatePosition()
if (!playback) {
return
}
cleanups.push(watch(playback.isPlaying, (isPlaying) => {
if (isPlaying) {
raf.resume()
}
else {
raf.pause()
updatePosition()
}
}))
cleanups.push(watch(playback.startPosition, () => {
raf.pause()
updatePosition()
if (playback.isPlaying.value) {
raf.resume()
}
}))
cleanups.push(() => raf.pause())
})
const stop = () => {
stopWatch()
cleanup()
}
tryOnScopeDispose(cleanup)
return { stop, position }
}
export function togglePlayStop(
player: PlayerControls | null,
options?: StopOptions,
) {
if (!player) {
return
}
if (player.playback.isPlaying.value) {
player.stop(options)
}
else {
player.play()
}
}
class AudioEngine {
audioCtx: AudioContext | null = null
masterGain: GainNode | null = null // controlled by UI volume slider
// fadeGain: GainNode | null = null; // tiny fade to avoid clicks
// cache of decoded buffers by URL
bufferCache = new Map<string, AudioBuffer>()
private _player: Ref<PlayerControls | null> = shallowRef(null)
// readonly player: Readonly<Ref<PlayerControls | null>> = this._player;
// settings
fadeDuration = 0.025 // 25 ms for fade-in/fade-out
init() {
if (this.audioCtx) {
return
}
this.audioCtx
= new (window.AudioContext || (window as any).webkitAudioContext)()
this.masterGain = this.audioCtx.createGain()
// routing: sources -> fadeGain -> masterGain -> destination
this.masterGain.connect(this.audioCtx.destination)
// default full volume
this.masterGain.gain.value = 1
}
shutdown() {
this.stopPlayer()
this.audioCtx?.close()
this.audioCtx = null
this.masterGain = null
}
async fetchAudioBuffer(
url: string,
signal?: AbortSignal,
): Promise<AudioBuffer> {
this.init()
if (this.bufferCache.has(url)) {
return this.bufferCache.get(url)!
}
const res = await fetch(url, { signal })
if (!res.ok) {
throw new Error(`Network error ${res.status} when fetching ${url}`)
}
const arrayBuffer = await res.arrayBuffer()
const audioBuffer = await this.audioCtx!.decodeAudioData(arrayBuffer)
this.bufferCache.set(url, audioBuffer)
return audioBuffer
}
// set UI volume 0..VOLUME_MAX
setVolume(value: number) {
this.init()
if (!this.masterGain || !this.audioCtx) {
return
}
const now = this.audioCtx.currentTime
// small linear ramp to avoid jumps
this.masterGain.gain.cancelScheduledValues(now)
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now)
this.masterGain.gain.linearRampToValueAtTime(value, now + 0.05)
}
initPlayer(
audioTrack: AudioTrack,
): PlayerControls | null {
this.init()
if (!this.audioCtx || !this.masterGain) {
return null
}
this.stopPlayer()
if (!audioTrack.loadedIntro || !audioTrack.loadedLoop) {
return null
}
const player = reusableAudioBuffersTrackPlayer(
this.audioCtx,
this.masterGain,
audioTrack,
)
this._player.value = player
return player
}
private stopPlayer() {
if (this._player.value) {
this._player.value.stop()
this._player.value = null
}
}
}
const audioEngine = new AudioEngine()
export default audioEngine