/* 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 extends PlayerHandle { playerResult: Omit } /** * 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( 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 { 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> /** * Readonly reference to the last remembered start-of-playback position. * * Will only update if stop(rememberPosition=true) or seek() is called. */ readonly startPosition: Readonly> /** * 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 | 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> } export function useLivePlaybackPosition( playback: MaybeRefOrGetter, 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(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() private _player: Ref = shallowRef(null) // readonly player: Readonly> = 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 { 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