358 lines
10 KiB
JavaScript
358 lines
10 KiB
JavaScript
'use strict'
|
|
|
|
/**
|
|
* Module dependencies.
|
|
*/
|
|
|
|
const os = require('os')
|
|
const debug = require('debug')('speaker')
|
|
const binding = require('bindings')('binding')
|
|
const bufferAlloc = require('buffer-alloc')
|
|
const Writable = require('readable-stream/writable')
|
|
|
|
// determine the native host endianness, the only supported playback endianness
|
|
const endianness = os.endianness()
|
|
|
|
/**
|
|
* The `Speaker` class accepts raw PCM data written to it, and then sends that data
|
|
* to the default output device of the OS.
|
|
*
|
|
* @param {Object} opts options object
|
|
* @api public
|
|
*/
|
|
|
|
class Speaker extends Writable {
|
|
constructor (opts) {
|
|
// default lwm and hwm to 0
|
|
if (!opts) opts = {}
|
|
if (opts.lowWaterMark == null) opts.lowWaterMark = 0
|
|
if (opts.highWaterMark == null) opts.highWaterMark = 0
|
|
|
|
super(opts)
|
|
|
|
// chunks are sent over to the backend in "samplesPerFrame * blockAlign" size.
|
|
// this is necessary because if we send too big of chunks at once, then there
|
|
// won't be any data ready when the audio callback comes (experienced with the
|
|
// CoreAudio backend)
|
|
this.samplesPerFrame = 1024
|
|
|
|
// the `audio_output_t` struct pointer Buffer instance
|
|
this.audio_handle = null
|
|
|
|
// flipped after close() is called, no write() calls allowed after
|
|
this._closed = false
|
|
|
|
// set PCM format
|
|
this._format(opts)
|
|
|
|
// bind event listeners
|
|
this._format = this._format.bind(this)
|
|
this.on('finish', this._flush)
|
|
this.on('pipe', this._pipe)
|
|
this.on('unpipe', this._unpipe)
|
|
}
|
|
|
|
/**
|
|
* Calls the audio backend's `open()` function, and then emits an "open" event.
|
|
*
|
|
* @api private
|
|
*/
|
|
|
|
_open () {
|
|
debug('open()')
|
|
if (this.audio_handle) {
|
|
throw new Error('_open() called more than once!')
|
|
}
|
|
// set default options, if not set
|
|
if (this.channels == null) {
|
|
debug('setting default %o: %o', 'channels', 2)
|
|
this.channels = 2
|
|
}
|
|
if (this.bitDepth == null) {
|
|
const depth = this.float ? 32 : 16
|
|
debug('setting default %o: %o', 'bitDepth', depth)
|
|
this.bitDepth = depth
|
|
}
|
|
if (this.sampleRate == null) {
|
|
debug('setting default %o: %o', 'sampleRate', 44100)
|
|
this.sampleRate = 44100
|
|
}
|
|
if (this.signed == null) {
|
|
debug('setting default %o: %o', 'signed', this.bitDepth !== 8)
|
|
this.signed = this.bitDepth !== 8
|
|
}
|
|
if (this.device == null) {
|
|
debug('setting default %o: %o', 'device', null)
|
|
this.device = null
|
|
}
|
|
|
|
const format = Speaker.getFormat(this)
|
|
if (format == null) {
|
|
throw new Error('invalid PCM format specified')
|
|
}
|
|
|
|
if (!Speaker.isSupported(format)) {
|
|
throw new Error(`specified PCM format is not supported by "${binding.name}" backend`)
|
|
}
|
|
|
|
// calculate the "block align"
|
|
this.blockAlign = this.bitDepth / 8 * this.channels
|
|
|
|
// initialize the audio handle
|
|
// TODO: open async?
|
|
this.audio_handle = bufferAlloc(binding.sizeof_audio_output_t)
|
|
const r = binding.open(this.audio_handle, this.channels, this.sampleRate, format, this.device)
|
|
if (r !== 0) {
|
|
throw new Error(`open() failed: ${r}`)
|
|
}
|
|
|
|
this.emit('open')
|
|
return this.audio_handle
|
|
}
|
|
|
|
/**
|
|
* Set given PCM formatting options. Called during instantiation on the passed in
|
|
* options object, on the stream given to the "pipe" event, and a final time if
|
|
* that stream emits a "format" event.
|
|
*
|
|
* @param {Object} opts
|
|
* @api private
|
|
*/
|
|
|
|
_format (opts) {
|
|
debug('format(object keys = %o)', Object.keys(opts))
|
|
if (opts.channels != null) {
|
|
debug('setting %o: %o', 'channels', opts.channels)
|
|
this.channels = opts.channels
|
|
}
|
|
if (opts.bitDepth != null) {
|
|
debug('setting %o: %o', 'bitDepth', opts.bitDepth)
|
|
this.bitDepth = opts.bitDepth
|
|
}
|
|
if (opts.sampleRate != null) {
|
|
debug('setting %o: %o', 'sampleRate', opts.sampleRate)
|
|
this.sampleRate = opts.sampleRate
|
|
}
|
|
if (opts.float != null) {
|
|
debug('setting %o: %o', 'float', opts.float)
|
|
this.float = opts.float
|
|
}
|
|
if (opts.signed != null) {
|
|
debug('setting %o: %o', 'signed', opts.signed)
|
|
this.signed = opts.signed
|
|
}
|
|
if (opts.samplesPerFrame != null) {
|
|
debug('setting %o: %o', 'samplesPerFrame', opts.samplesPerFrame)
|
|
this.samplesPerFrame = opts.samplesPerFrame
|
|
}
|
|
if (opts.device != null) {
|
|
debug('setting %o: %o', 'device', opts.device)
|
|
this.device = opts.device
|
|
}
|
|
if (opts.endianness == null || endianness === opts.endianness) {
|
|
// no "endianness" specified or explicit native endianness
|
|
this.endianness = endianness
|
|
} else {
|
|
// only native endianness is supported...
|
|
this.emit('error', new Error(`only native endianness ("${endianness}") is supported, got "${opts.endianness}"`))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `_write()` callback for the Writable base class.
|
|
*
|
|
* @param {Buffer} chunk
|
|
* @param {String} encoding
|
|
* @param {Function} done
|
|
* @api private
|
|
*/
|
|
|
|
_write (chunk, encoding, done) {
|
|
debug('_write() (%o bytes)', chunk.length)
|
|
|
|
if (this._closed) {
|
|
// close() has already been called. this should not be called
|
|
return done(new Error('write() call after close() call'))
|
|
}
|
|
let b
|
|
let left = chunk
|
|
let handle = this.audio_handle
|
|
if (!handle) {
|
|
// this is the first time write() is being called; need to _open()
|
|
try {
|
|
handle = this._open()
|
|
} catch (e) {
|
|
return done(e)
|
|
}
|
|
}
|
|
const chunkSize = this.blockAlign * this.samplesPerFrame
|
|
|
|
const write = () => {
|
|
if (this._closed) {
|
|
debug('aborting remainder of write() call (%o bytes), since speaker is `_closed`', left.length)
|
|
return done()
|
|
}
|
|
b = left
|
|
if (b.length > chunkSize) {
|
|
const t = b
|
|
b = t.slice(0, chunkSize)
|
|
left = t.slice(chunkSize)
|
|
} else {
|
|
left = null
|
|
}
|
|
debug('writing %o byte chunk', b.length)
|
|
binding.write(handle, b, b.length, onwrite)
|
|
}
|
|
|
|
const onwrite = (r) => {
|
|
debug('wrote %o bytes', r)
|
|
if (r !== b.length) {
|
|
done(new Error(`write() failed: ${r}`))
|
|
} else if (left) {
|
|
debug('still %o bytes left in this chunk', left.length)
|
|
write()
|
|
} else {
|
|
debug('done with this chunk')
|
|
done()
|
|
}
|
|
}
|
|
|
|
write()
|
|
}
|
|
|
|
/**
|
|
* Called when this stream is pipe()d to from another readable stream.
|
|
* If the "sampleRate", "channels", "bitDepth", and "signed" properties are
|
|
* set, then they will be used over the currently set values.
|
|
*
|
|
* @api private
|
|
*/
|
|
|
|
_pipe (source) {
|
|
debug('_pipe()')
|
|
this._format(source)
|
|
source.once('format', this._format)
|
|
}
|
|
|
|
/**
|
|
* Called when this stream is pipe()d to from another readable stream.
|
|
* If the "sampleRate", "channels", "bitDepth", and "signed" properties are
|
|
* set, then they will be used over the currently set values.
|
|
*
|
|
* @api private
|
|
*/
|
|
|
|
_unpipe (source) {
|
|
debug('_unpipe()')
|
|
source.removeListener('format', this._format)
|
|
}
|
|
|
|
/**
|
|
* Emits a "flush" event and then calls the `.close()` function on
|
|
* this Speaker instance.
|
|
*
|
|
* @api private
|
|
*/
|
|
|
|
_flush () {
|
|
debug('_flush()')
|
|
this.emit('flush')
|
|
this.close(false)
|
|
}
|
|
|
|
/**
|
|
* Closes the audio backend. Normally this function will be called automatically
|
|
* after the audio backend has finished playing the audio buffer through the
|
|
* speakers.
|
|
*
|
|
* @param {Boolean} flush - if `false`, then don't call the `flush()` native binding call. Defaults to `true`.
|
|
* @api public
|
|
*/
|
|
|
|
close (flush) {
|
|
debug('close(%o)', flush)
|
|
if (this._closed) return debug('already closed...')
|
|
|
|
if (this.audio_handle) {
|
|
if (flush !== false) {
|
|
// TODO: async most likely…
|
|
debug('invoking flush() native binding')
|
|
binding.flush(this.audio_handle)
|
|
}
|
|
|
|
// TODO: async maybe?
|
|
debug('invoking close() native binding')
|
|
binding.close(this.audio_handle)
|
|
this.audio_handle = null
|
|
} else {
|
|
debug('not invoking flush() or close() bindings since no `audio_handle`')
|
|
}
|
|
|
|
this._closed = true
|
|
this.emit('close')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export information about the `mpg123_module_t` being used.
|
|
*/
|
|
|
|
Speaker.api_version = binding.api_version
|
|
Speaker.description = binding.description
|
|
Speaker.module_name = binding.name
|
|
|
|
/**
|
|
* Returns the `MPG123_ENC_*` constant that corresponds to the given "format"
|
|
* object, or `null` if the format is invalid.
|
|
*
|
|
* @param {Object} format - format object with `channels`, `sampleRate`, `bitDepth`, etc.
|
|
* @return {Number} MPG123_ENC_* constant, or `null`
|
|
* @api public
|
|
*/
|
|
|
|
Speaker.getFormat = function getFormat (format) {
|
|
if (Number(format.bitDepth) === 32 && format.float && format.signed) {
|
|
return binding.MPG123_ENC_FLOAT_32
|
|
} else if (Number(format.bitDepth) === 64 && format.float && format.signed) {
|
|
return binding.MPG123_ENC_FLOAT_64
|
|
} else if (Number(format.bitDepth) === 8 && format.signed) {
|
|
return binding.MPG123_ENC_SIGNED_8
|
|
} else if (Number(format.bitDepth) === 8 && !format.signed) {
|
|
return binding.MPG123_ENC_UNSIGNED_8
|
|
} else if (Number(format.bitDepth) === 16 && format.signed) {
|
|
return binding.MPG123_ENC_SIGNED_16
|
|
} else if (Number(format.bitDepth) === 16 && !format.signed) {
|
|
return binding.MPG123_ENC_UNSIGNED_16
|
|
} else if (Number(format.bitDepth) === 24 && format.signed) {
|
|
return binding.MPG123_ENC_SIGNED_24
|
|
} else if (Number(format.bitDepth) === 24 && !format.signed) {
|
|
return binding.MPG123_ENC_UNSIGNED_24
|
|
} else if (Number(format.bitDepth) === 32 && format.signed) {
|
|
return binding.MPG123_ENC_SIGNED_32
|
|
} else if (Number(format.bitDepth) === 32 && !format.signed) {
|
|
return binding.MPG123_ENC_UNSIGNED_32
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if the given "format" is playable via the "output module"
|
|
* that was selected during compilation, or `false` if not playable.
|
|
*
|
|
* @param {Number} format - MPG123_ENC_* format constant
|
|
* @return {Boolean} true if the format is playable, false otherwise
|
|
* @api public
|
|
*/
|
|
|
|
Speaker.isSupported = function isSupported (format) {
|
|
if (typeof format !== 'number') format = Speaker.getFormat(format)
|
|
return (binding.formats & format) === format
|
|
}
|
|
|
|
/**
|
|
* Module exports.
|
|
*/
|
|
|
|
exports = module.exports = Speaker
|