ts-audio
Version:
`ts-audio` is an agnostic and easy-to-use library to work with the `AudioContext` API and create Playlists.
270 lines (242 loc) • 7.9 kB
text/typescript
import { AudioCtx } from './AudioCtx'
import { defaultStates } from './states'
import { EventEmitter } from '../EventEmitter'
import { EventHandler } from '../EventHandler'
import { decodeAudioData } from './decodeAudioData'
import { initializeSource } from './initializeSource'
import { getBuffer, preloadFile } from './utils'
/**
* Configuration options for creating an Audio instance.
*/
type AudioProp = {
/** Path or URL to the audio file */
file: string
/** Initial volume level (0 to 1) */
volume?: number
/** Time in seconds to start playback */
time?: number
/** Whether to start playing automatically */
autoPlay?: boolean
/** Whether to loop the audio */
loop?: boolean
/** Whether to preload the audio file */
preload?: boolean
}
/**
* Valid event types that can be emitted by the Audio instance.
*/
type AudioEvent = 'ready' | 'start' | 'state' | 'end'
/**
* If `AudioContext` is initialized before a user gesture on the page, its
* state becomes `suspended` by default. Once `AudioContext.state` is `suspended`,
* the only way to start it after a user gesture is executing the `resume` method.
*/
const start = (audioCtx: AudioContext, source: AudioBufferSourceNode, time: number) =>
audioCtx.state === 'suspended'
? audioCtx.resume().then(() => source.start(0, time))
: source.start(0, time)
/**
* Audio player class that provides control over a single audio file.
* Implements the AudioType interface for managing audio playback, volume, and events.
*/
export class AudioClass {
/** @private Path or URL to the audio file */
private _file: AudioProp['file']
/** @private Initial volume level set during construction */
private _initialVolume: number
/** @private Initial time in seconds to start playback */
private _initialTime: number
/** @private Flag indicating if audio should play automatically */
private _autoPlay: boolean
/** @private Initial loop state set during construction */
private _initialLoop: boolean
/** @private Web Audio API context */
private _audioCtx: AudioContext
/** @private Internal state management object */
private _states: typeof defaultStates
/** @private Event emitter for handling audio events */
private _emitter: EventEmitter
/** @private Event handler for managing event subscriptions */
private _eventHandler: EventHandler
/**
* Creates an instance of Audio player.
*
* @param {AudioProp} config - The audio configuration object
* @param {string} config.file - Path or URL to the audio file
* @param {number} [config.volume=1] - Initial volume level (0 to 1)
* @param {number} [config.time=0] - Time in seconds to start playback
* @param {boolean} [config.autoPlay=false] - Whether to start playing automatically
* @param {boolean} [config.loop=false] - Whether to loop the audio
* @param {boolean} [config.preload=false] - Whether to preload the audio file
*/
constructor({
file,
volume = 1,
time = 0,
autoPlay = false,
loop = false,
preload = false,
}: AudioProp) {
this._file = file
this._initialVolume = volume
this._initialTime = time
this._autoPlay = autoPlay
this._initialLoop = loop
this._audioCtx = AudioCtx()
this._states = { ...defaultStates }
this._emitter = new EventEmitter()
this._eventHandler = new EventHandler(this._emitter, this._audioCtx)
if (preload) {
preloadFile(file)
}
}
/**
* Fetches and decodes the audio buffer for the given source node.
* @private
* @param {AudioBufferSourceNode} source - The audio source node to load buffer into
*/
private curryGetBuffer(source: AudioBufferSourceNode): void {
this._states.isDecoded = false
getBuffer(this._file)
.then((arrayBuffer) => {
decodeAudioData({
audioCtx: this._audioCtx,
source,
arrayBuffer,
autoPlay: this._autoPlay,
loop: this._initialLoop,
states: this._states,
emitter: this._emitter,
})
})
.catch(console.error)
}
/**
* Starts or resumes audio playback.
* If playback hasn't started, initializes audio source and begins playback.
* If playback was paused, resumes from the current position.
*/
public play(): void {
if (this._states.hasStarted) {
this._audioCtx.resume()
this._states.isPlaying = true
return
}
initializeSource({
audioCtx: this._audioCtx,
volume: this._initialVolume,
emitter: this._emitter,
states: this._states,
})
const { source } = this._states
if (source) {
this.curryGetBuffer(source)
if (this._states.isDecoded) {
start(this._audioCtx, source, this._initialTime)
} else {
this._emitter.listener('decoded', () => start(this._audioCtx, source, this._initialTime))
}
this._states.hasStarted = true
this._states.isPlaying = true
this._emitter.emit('start', { data: null })
}
}
/**
* Pauses audio playback by suspending the audio context.
*/
public pause(): void {
this._audioCtx.suspend()
this._states.isPlaying = false
}
/**
* Toggles between play and pause states.
*/
public toggle(): void {
this._states.isPlaying ? this.pause() : this.play()
}
/**
* Stops audio playback completely.
* Different from pause as it resets the playback position.
*/
public stop(): void {
if (this._states.hasStarted) {
this._states.source?.stop(0)
this._states.isPlaying = false
}
}
/**
* Subscribes to audio events.
* @param {AudioEvent} eventType - Type of event to listen for
* @param {Function} callback - Function to call when event occurs
*/
public on(eventType: AudioEvent, callback: <T>(param: { [data: string]: T }) => void): void {
this._eventHandler[eventType]?.(callback)
}
/**
* Gets the current volume level.
* @returns {number} Current volume value between 0 and 1
*/
public get volume(): number {
return this._states.gainNode?.gain.value ?? 0
}
/**
* Sets the audio volume level.
* @param {number} newVolume - New volume value between 0 and 1
*/
public set volume(newVolume: number) {
if (this._states.gainNode) {
this._states.gainNode.gain.value = newVolume
}
}
/**
* Gets the current loop state.
* @returns {boolean} Whether audio is set to loop
*/
public get loop(): boolean {
return this._states.source?.loop ?? false
}
/**
* Sets the loop state.
* @param {boolean} newLoop - Whether audio should loop
*/
public set loop(newLoop: boolean) {
if (this._states.source) {
this._states.source.loop = newLoop
}
}
/**
* Gets the current state of the audio context.
* @returns {AudioContextState} Current state of the audio context
*/
public get state(): AudioContextState {
return this._audioCtx.state
}
/**
* Gets the current AudioContext instance.
* @returns {AudioContext} The current AudioContext instance
*/
public get audioCtx(): AudioContext {
return this._audioCtx
}
/**
* Gets the total duration of the loaded audio in seconds.
* @returns {number} The duration of the audio if available; otherwise, returns 0.
*/
public get duration(): number {
return this._states.source?.buffer?.duration ?? 0
}
/**
* Gets the current playback position in seconds.
* @returns {number} The current playback position if available; otherwise, returns 0.
*/
public get currentTime(): number {
return this._states.source?.context.currentTime ?? 0
}
}
/**
* Factory function to create a new Audio instance.
*
* @param {AudioPropType} props - The audio configuration properties
* @returns {AudioType} A new Audio instance
*/
export default (props: AudioProp): AudioClass => new AudioClass(props)