261 lines
6.9 KiB
Plaintext
261 lines
6.9 KiB
Plaintext
/*
|
|
* soundwave.cm - Standalone audio playback system
|
|
*
|
|
* Creates an audio player instance that manages voices and provides
|
|
* mixed audio output. Platform-agnostic - caller is responsible for
|
|
* feeding the output to the audio device.
|
|
*
|
|
* USAGE:
|
|
* var soundwave = use('soundwave/soundwave')
|
|
* var player = soundwave.create({
|
|
* sample_rate: 44100,
|
|
* channels: 2,
|
|
* frames_per_chunk: 1024
|
|
* })
|
|
*
|
|
* // Load and decode audio (caller provides bytes and file path)
|
|
* var pcm = player.decode(bytes, "mysound.mp3")
|
|
*
|
|
* // Play a sound
|
|
* var voice = player.play(pcm, { loop: true, vol: 0.5 })
|
|
* voice.stopped = true // stop it
|
|
*
|
|
* // Pull mixed audio frames for output
|
|
* var blob = player.pull(1024) // returns stoned blob of f32 stereo samples
|
|
*
|
|
* OBJECTS:
|
|
*
|
|
* Player - Audio player instance
|
|
* .sample_rate - output sample rate (default 44100)
|
|
* .channels - output channels (default 2)
|
|
* .frames_per_chunk- default frames per pull (default 1024)
|
|
* .decode(bytes, path) - decode audio bytes, returns PCM object
|
|
* .play(pcm, opts) - play a PCM, returns Voice object
|
|
* .pull(frames) - pull mixed audio, returns stoned blob
|
|
* .cleanup() - remove finished voices
|
|
*
|
|
* PCM - Decoded audio data
|
|
* .pcm - stoned blob of f32 stereo samples at player's sample_rate
|
|
* .channels - channel count (after conversion)
|
|
* .sample_rate- sample rate (after conversion)
|
|
* .frames - total frames in pcm blob
|
|
* .path - source file path
|
|
*
|
|
* Voice - A playing instance of a PCM
|
|
* .source - reference to PCM object
|
|
* .pos - current frame position (0-indexed)
|
|
* .vol - volume 0.0-1.0 (default 1.0)
|
|
* .loop - if true, loops when reaching end
|
|
* .stopped - set to true to stop playback
|
|
* .finish_hook- optional callback when voice finishes
|
|
*/
|
|
|
|
var wav = use('wav')
|
|
var mp3 = use('mp3')
|
|
var flac = use('flac')
|
|
var dsp = use('dsp')
|
|
var samplerate = use('libsamplerate/convert')
|
|
var Blob = use('blob')
|
|
|
|
var soundwave = {}
|
|
|
|
var voice = {
|
|
stop() {
|
|
this.stopped = true
|
|
}
|
|
}
|
|
|
|
// Create a new audio player instance
|
|
soundwave.create = function(opts) {
|
|
opts = opts || {}
|
|
|
|
var player = {
|
|
sample_rate: opts.sample_rate || 44100,
|
|
channels: opts.channels || 2,
|
|
frames_per_chunk: opts.frames_per_chunk || 1024,
|
|
voices: [],
|
|
pcm_cache: {}
|
|
}
|
|
|
|
var BYTES_PER_SAMPLE = 4 // f32
|
|
|
|
// Normalize decoded audio to player's output format
|
|
function normalize_pcm(decoded, path) {
|
|
var pcm = decoded.pcm
|
|
var channels = decoded.channels || 1
|
|
var rate = decoded.sample_rate || player.sample_rate
|
|
|
|
// Resample if needed
|
|
if (rate != player.sample_rate) {
|
|
pcm = samplerate.resample(pcm, rate, player.sample_rate, channels)
|
|
}
|
|
|
|
// Convert mono to stereo if needed
|
|
if (channels == 1 && player.channels == 2) {
|
|
pcm = dsp.mono_to_stereo(pcm)
|
|
channels = 2
|
|
}
|
|
|
|
// Calculate frames
|
|
var bytes = length(pcm) / 8 // length(blob) is in bits
|
|
var frames = bytes / (player.channels * BYTES_PER_SAMPLE)
|
|
|
|
return {
|
|
pcm: pcm,
|
|
channels: player.channels,
|
|
sample_rate: player.sample_rate,
|
|
frames: frames,
|
|
path: path
|
|
}
|
|
}
|
|
|
|
// Decode audio bytes into PCM
|
|
// bytes: blob of encoded audio data
|
|
// path: file path (used to determine format and for caching)
|
|
player.decode = function(bytes, path) {
|
|
if (!bytes || !path) return null
|
|
|
|
// Check cache
|
|
if (player.pcm_cache[path]) return player.pcm_cache[path]
|
|
|
|
var decoded = null
|
|
if (ends_with(path, '.wav')) {
|
|
decoded = wav.decode(bytes)
|
|
} else if (ends_with(path, '.mp3')) {
|
|
decoded = mp3.decode(bytes)
|
|
} else if (ends_with(path, '.flac')) {
|
|
decoded = flac.decode(bytes)
|
|
}
|
|
|
|
if (decoded && decoded.pcm) {
|
|
var normalized = normalize_pcm(decoded, path)
|
|
player.pcm_cache[path] = normalized
|
|
return normalized
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Pull a chunk of audio from a voice, handling looping
|
|
function pull_voice_chunk(voice, frames) {
|
|
if (voice.stopped) return null
|
|
|
|
var source = voice.source
|
|
var total_frames = source.frames
|
|
var pos = voice.pos
|
|
var bytes_per_frame = player.channels * BYTES_PER_SAMPLE
|
|
var bits_per_frame = bytes_per_frame * 8
|
|
|
|
var out = Blob()
|
|
var frames_written = 0
|
|
|
|
while (frames_written < frames) {
|
|
if (pos >= total_frames) {
|
|
if (voice.loop) {
|
|
pos = 0
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
var frames_available = total_frames - pos
|
|
var frames_needed = frames - frames_written
|
|
var frames_to_read = frames_available < frames_needed ? frames_available : frames_needed
|
|
|
|
var start_bit = pos * bits_per_frame
|
|
var end_bit = (pos + frames_to_read) * bits_per_frame
|
|
var chunk = source.pcm.read_blob(start_bit, end_bit)
|
|
out.write_blob(chunk)
|
|
|
|
pos += frames_to_read
|
|
frames_written += frames_to_read
|
|
}
|
|
|
|
voice.pos = pos
|
|
|
|
// Pad with silence if needed
|
|
if (frames_written < frames) {
|
|
var silence_frames = frames - frames_written
|
|
var silence = dsp.silence(silence_frames, player.channels)
|
|
out.write_blob(silence)
|
|
}
|
|
|
|
stone(out)
|
|
return out
|
|
}
|
|
|
|
// Remove finished voices
|
|
player.cleanup = function() {
|
|
var active = []
|
|
for (var i = 0; i < length(player.voices); i++) {
|
|
var v = player.voices[i]
|
|
var done = v.stopped || (!v.loop && v.pos >= v.source.frames)
|
|
if (!done) {
|
|
push(active, v)
|
|
} else if (v.finish_hook) {
|
|
v.finish_hook()
|
|
}
|
|
}
|
|
player.voices = active
|
|
}
|
|
|
|
// Play a PCM, returns voice object
|
|
player.play = function(pcm, opts) {
|
|
if (!pcm) return null
|
|
|
|
var newvoice = meme(voice, {
|
|
source: pcm,
|
|
pos: 0,
|
|
vol: 1.0,
|
|
loop: false,
|
|
stopped: false,
|
|
finish_hook: null
|
|
})
|
|
|
|
if (opts) {
|
|
if (opts.loop != null) newvoice.loop = opts.loop
|
|
if (opts.vol != null) newvoice.vol = opts.vol
|
|
}
|
|
|
|
push(player.voices, newvoice)
|
|
return newvoice
|
|
}
|
|
|
|
// Pull mixed audio frames
|
|
// Returns a stoned blob of f32 samples (channels * frames * 4 bytes)
|
|
player.pull = function(frames) {
|
|
frames = frames || player.frames_per_chunk
|
|
|
|
var blobs = []
|
|
var vols = []
|
|
|
|
for (var i = 0; i < length(player.voices); i++) {
|
|
var v = player.voices[i]
|
|
if (v.stopped) continue
|
|
var chunk = pull_voice_chunk(v, frames)
|
|
if (chunk) {
|
|
push(blobs, chunk)
|
|
push(vols, v.vol)
|
|
}
|
|
}
|
|
|
|
var mixed
|
|
if (length(blobs) == 0) {
|
|
mixed = dsp.silence(frames, player.channels)
|
|
} else {
|
|
mixed = dsp.mix_blobs(blobs, vols)
|
|
}
|
|
|
|
player.cleanup()
|
|
return mixed
|
|
}
|
|
|
|
// Convenience: get number of active voices
|
|
player.voice_count = function() {
|
|
return length(player.voices)
|
|
}
|
|
|
|
return player
|
|
}
|
|
|
|
return soundwave
|