ts-audio
Version:
477 lines (416 loc) • 13.6 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
/** @private Track when playback started for currentTime calculation */
private _startTime = 0
/** @private Track pause position for accurate seeking */
private _pauseTime = 0
/** @private Flag to track if seeking occurred while audio was paused */
private _hasSeekedWhilePaused = false
/** @private Store seek time requested before audio is decoded */
private _pendingSeekTime: number | null = null
/** @private Flag to track if the instance has been destroyed */
private _isDestroyed = false
/**
* 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)
}
}
/**
* Recreates source and starts playback at specified time.
* @private
* @param {number} time - Time in seconds to start playback
* @param {AudioBuffer} buffer - The audio buffer to use
*/
private recreateAndStart(time: number, buffer: AudioBuffer): void {
try {
// Stop current source if playing
if (this._states.source) {
this._states.source.stop(0)
this._states.source.onended = null
}
initializeSource({
audioCtx: this._audioCtx,
volume: this._states.gainNode?.gain.value ?? this._initialVolume,
emitter: this._emitter,
states: this._states,
})
const { source } = this._states
if (source) {
source.buffer = buffer
source.loop = this._initialLoop
start(this._audioCtx, source, time)
this._startTime = this._audioCtx.currentTime
this._pauseTime = time
this._states.isPlaying = true
this._states.hasStarted = true
}
} catch (error) {
console.error('Failed to recreate audio source:', error)
this._states.isPlaying = false
}
}
/**
* 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.
* If seeking occurred while paused, recreates the source at the new position.
*/
public play(): void {
if (this._isDestroyed) {
return
}
if (this._states.hasStarted && !this._hasSeekedWhilePaused) {
this._audioCtx.resume()
this._startTime = this._audioCtx.currentTime
this._states.isPlaying = true
return
}
// If seeked while paused, recreate source at the new position
if (this._hasSeekedWhilePaused && this._states.source?.buffer) {
const audioBuffer = this._states.source.buffer
this.recreateAndStart(this._pauseTime, audioBuffer)
this._hasSeekedWhilePaused = false
return
}
initializeSource({
audioCtx: this._audioCtx,
volume: this._initialVolume,
emitter: this._emitter,
states: this._states,
})
// Apply pending seek if one was requested before audio was ready
if (this._pendingSeekTime !== null) {
this._pauseTime = this._pendingSeekTime
this._pendingSeekTime = null
}
const { source } = this._states
if (source) {
this.curryGetBuffer(source)
if (this._states.isDecoded) {
start(this._audioCtx, source, this._pauseTime ?? this._initialTime)
this._startTime = this._audioCtx.currentTime
} else {
this._emitter.listener('decoded', () => {
start(this._audioCtx, source, this._pauseTime ?? this._initialTime)
this._startTime = this._audioCtx.currentTime
})
}
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 {
if (this._isDestroyed) {
return
}
if (this._states.isPlaying) {
this._pauseTime = this.currentTime
}
this._audioCtx.suspend()
this._states.isPlaying = false
this._hasSeekedWhilePaused = false
}
/**
* Toggles between play and pause states.
*/
public toggle(): void {
if (this._isDestroyed) {
return
}
if (this._states.isPlaying) {
this.pause()
} else {
this.play()
}
}
/**
* Stops audio playback completely.
* Different from pause as it resets the playback position.
*/
public stop(): void {
if (this._isDestroyed) {
return
}
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 {
if (this._isDestroyed) {
return
}
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._isDestroyed) {
return
}
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._isDestroyed) {
return
}
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 {
if (!this._states.hasStarted) {
return 0
}
if (!this._states.isPlaying) {
return this._pauseTime
}
return this._pauseTime + (this._audioCtx.currentTime - this._startTime)
}
/**
* Indicates whether the audio is currently playing.
* @returns {boolean}
*/
public get isPlaying(): boolean {
return this._states.isPlaying
}
/**
* Seeks to a specific time position in the audio track.
* @param {number} time - Time in seconds to seek to (0 ≤ time ≤ duration)
*/
public seek(time: number): void {
if (this._isDestroyed) {
return
}
// Clamp time if we know duration, otherwise trust the value
const clampedTime =
this.duration > 0 ? Math.max(0, Math.min(time, this.duration)) : Math.max(0, time)
// If audio not decoded yet, store pending seek
if (!this._states.isDecoded || !this._states.source?.buffer) {
this._pendingSeekTime = clampedTime
return
}
// Clear any pending seek
this._pendingSeekTime = null
// Re-clamp now that we have actual duration
const finalTime = Math.max(0, Math.min(clampedTime, this.duration))
const wasPlaying = this._states.isPlaying
const audioBuffer = this._states.source.buffer
if (wasPlaying && this._states.source) {
// Temporarily remove onended handler to prevent false "end" events during seek
this._states.source.onended = null
try {
this._states.source.stop(0)
} catch (error) {
console.error('Error stopping audio source:', error)
}
this.recreateAndStart(finalTime, audioBuffer)
} else {
// Just update pause position for paused audio
this._pauseTime = finalTime
this._hasSeekedWhilePaused = true
}
}
/**
* Destroys the Audio instance, stopping playback, disconnecting audio nodes,
* removing event listeners, and releasing references.
* This method is idempotent and safe to call multiple times.
*/
public destroy(): void {
if (this._isDestroyed) {
return
}
// Stop playback if active
if (this._states.source) {
this._states.source.onended = null
try {
this._states.source.stop(0)
} catch {
// Ignore errors if source is already stopped
}
this._states.source.disconnect()
}
// Disconnect gain node
this._states.gainNode?.disconnect()
// Dispose event handler
this._eventHandler.dispose()
// Clear all event listeners
this._emitter.removeAllListeners()
// Clear state references
this._states.source = null
this._states.gainNode = null
this._states.isPlaying = false
this._states.hasStarted = false
this._states.isDecoded = false
this._isDestroyed = true
}
}
/**
* 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)