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

555 lines
13 KiB
TypeScript
Raw Normal View History

WIP: Add frontend web app player & editor in Vue 3 + Vite TODO: - implement viewing & editing. - Add links to deployment, and CHANGELOG. style.css package.json vite config .vscode eslint use --cache .vscode add vite-css-modules editorconfig tsconfig and updated vue-tsc (fixes most of the type checking bugs) fix last type errors audiowaveform gitignore ESLint ESLint: ignore autogenerated JSON lint:fix tsconfig and vite config migrate icon generating script to TS eslint src/lib/ eslint stores eslint src/*.ts eslint audio pnpm update update icon eslint ahh import new tracks json instructions on jq codenames codenames.json fix styles broken by import order eslint audio app error screen footer copyright year global header loading screen transition search field preview track info inspector control controls controls range controls impl controls index eslint no-console off AudioTrack view inspector cards and sliders more controls master volume slider playhead library page player page timeline markers timeline markers header tick timestamp timeline clip index clip empty clip lyrics clip palette clip fadeout clip default import order timeline timeline panel timeline track header timeline trackview clip view clip audio audio waveform scrollsync easy lints eslint store eslint no mutating props off pnpm catalog off add unhead dep use head eslint inspector eslint easy minor stuff eslint audiowaveform easy fix eslint use :key with v-for fix audio waveforms inspector makes more sense season remove debug inspector
2025-11-27 14:57:28 +00:00
/*
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