forked from nikita/muzika-gromche
555 lines
13 KiB
TypeScript
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
|