From bd1de0e954e76aa953ebcbec7d9eb49df6c3e983 Mon Sep 17 00:00:00 2001 From: dj Date: Tue, 21 Nov 2017 21:08:49 -0800 Subject: [PATCH 1/6] emit progress event to give some playback data --- README.md | 64 +++--- index.js | 583 ++++++++++++++++++++++++++------------------------- package.json | 48 +++-- 3 files changed, 361 insertions(+), 334 deletions(-) diff --git a/README.md b/README.md index 67423f8..d1a4a2b 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,44 @@ -# node-speaker - -## Output [PCM audio][pcm] data to the speakers - +node-speaker +============ +### Output [PCM audio][pcm] data to the speakers [![Build Status](https://secure.travis-ci.org/TooTallNate/node-speaker.svg)](https://travis-ci.org/TooTallNate/node-speaker) [![Build Status](https://ci.appveyor.com/api/projects/status/wix7wml3v55670kw?svg=true)](https://ci.appveyor.com/project/TooTallNate/node-speaker) + A Writable stream instance that accepts [PCM audio][pcm] data and outputs it to the speakers. The output is backed by `mpg123`'s audio output modules, which in turn use any number of audio backends commonly found on Operating Systems these days. -## Installation + +Installation +------------ Simply compile and install `node-speaker` using `npm`: -```sh -npm install speaker +``` bash +$ npm install speaker ``` On Debian/Ubuntu, the [ALSA][alsa] backend is selected by default, so be sure to have the `alsa.h` header file in place: -```sh -sudo apt-get install libasound2-dev +``` bash +$ sudo apt-get install libasound2-dev ``` -## Example + +Example +------- Here's an example of piping `stdin` to the speaker, which should be 2 channel, 16-bit audio at 44,100 samples per second (a.k.a CD quality audio). -```javascript -const Speaker = require('speaker'); +``` javascript +var Speaker = require('speaker'); // Create the Speaker instance -const speaker = new Speaker({ +var speaker = new Speaker({ channels: 2, // 2 channels bitDepth: 16, // 16-bit samples sampleRate: 44100 // 44,100 Hz sample rate @@ -44,29 +48,39 @@ const speaker = new Speaker({ process.stdin.pipe(speaker); ``` -## API + +API +--- `require('speaker')` directly returns the `Speaker` constructor. It is the only interface exported by `node-speaker`. -### new Speaker([ format ]) -> Speaker instance +### new Speaker([ format ]) -> Speaker instance; Creates a new `Speaker` instance, which is a writable stream that you can pipe PCM audio data to. The optional `format` object may contain any of the `Writable` base class options, as well as any of these PCM formatting options: -* `channels` - The number of audio channels. PCM data must be interleaved. Defaults to `2`. -* `bitDepth` - The number of bits per sample. Defaults to `16` (16-bit). -* `sampleRate` - The number of samples per second per channel. Defaults to `44100`. -* `signed` - Boolean specifying if the samples are signed or unsigned. Defaults to `true` when bit depth is 8-bit, `false` otherwise. -* `float` - Boolean specifying if the samples are floating-point values. Defaults to `false`. -* `samplesPerFrame` - The number of samples to send to the audio backend at a time. You likely don't need to mess with this value. Defaults to `1024`. + * `channels` - The number of audio channels. PCM data must be interleaved. Defaults to `2`. + * `bitDepth` - The number of bits per sample. Defaults to `16` (16-bit). + * `sampleRate` - The number of samples per second per channel. Defaults to `44100`. + * `signed` - Boolean specifying if the samples are signed or unsigned. Defaults to `true` when bit depth is 8-bit, `false` otherwise. + * `float` - Boolean specifying if the samples are floating-point values. Defaults to `false`. + * `samplesPerFrame` - The number of samples to send to the audio backend at a time. You likely don't need to mess with this value. Defaults to `1024`. #### "open" event Fired when the backend `open()` call has completed. This happens once the first `write()` call happens on the speaker instance. +#### "progress" event + +Fired after each data write to the speaker instance. The parameter contains progress info: +* `numwr` - the cumulative number of writes (this is related to the number of frames) +* `wrlen` - the number of bytes written this time +* `wrtotal` - the total number of bytes written +* `buflen` - the number of bytes currently remaining in the buffer + #### "flush" event Fired after the speaker instance has had `end()` called, and after the audio data @@ -77,7 +91,9 @@ has been flushed to the speakers. Fired after the "flush" event, after the backend `close()` call has completed. This speaker instance is essentially finished after this point. -## Audio Backend Selection + +Audio Backend Selection +----------------------- `node-speaker` is backed by `mpg123`'s "output modules", which in turn use one of many popular audio backends like ALSA, OSS, SDL, and lots more. The default @@ -93,8 +109,8 @@ backends for each operating system are described in the table below: To manually override the default backend, pass the `--mpg123-backend` switch to `npm`/`node-gyp`: -```sh -npm install speaker --mpg123-backend=openal +``` bash +$ npm install speaker --mpg123-backend=openal ``` [pcm]: http://en.wikipedia.org/wiki/Pulse-code_modulation diff --git a/index.js b/index.js index 25d15d6..6b8dc69 100644 --- a/index.js +++ b/index.js @@ -1,349 +1,356 @@ -'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') +var os = require('os'); +var debug = require('debug')('speaker'); +var binding = require('bindings')('binding'); +var inherits = require('util').inherits; +var Writable = require('readable-stream/writable'); // determine the native host endianness, the only supported playback endianness -const endianness = os.endianness() +var endianness = 'function' == os.endianness ? + os.endianness() : + 'LE'; // assume little-endian for older versions of node.js /** - * The `Speaker` class accepts raw PCM data written to it, and then sends that data - * to the default output device of the OS. + * Module exports. + */ + +exports = module.exports = Speaker; + +/** + * Export information about the `mpg123_module_t` being used. + */ + +exports.api_version = binding.api_version; +exports.description = binding.description; +exports.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} opts options object + * @param {Object} format - format object with `channels`, `sampleRate`, `bitDepth`, etc. + * @return {Number} MPG123_ENC_* constant, or `null` * @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 +exports.getFormat = function getFormat (format) { + var f = null; + if (format.bitDepth == 32 && format.float && format.signed) { + f = binding.MPG123_ENC_FLOAT_32; + } else if (format.bitDepth == 64 && format.float && format.signed) { + f = binding.MPG123_ENC_FLOAT_64; + } else if (format.bitDepth == 8 && format.signed) { + f = binding.MPG123_ENC_SIGNED_8; + } else if (format.bitDepth == 8 && !format.signed) { + f = binding.MPG123_ENC_UNSIGNED_8; + } else if (format.bitDepth == 16 && format.signed) { + f = binding.MPG123_ENC_SIGNED_16; + } else if (format.bitDepth == 16 && !format.signed) { + f = binding.MPG123_ENC_UNSIGNED_16; + } else if (format.bitDepth == 24 && format.signed) { + f = binding.MPG123_ENC_SIGNED_24; + } else if (format.bitDepth == 24 && !format.signed) { + f = binding.MPG123_ENC_UNSIGNED_24; + } else if (format.bitDepth == 32 && format.signed) { + f = binding.MPG123_ENC_SIGNED_32; + } else if (format.bitDepth == 32 && !format.signed) { + f = binding.MPG123_ENC_UNSIGNED_32; + } + return f; +} - super(opts) +/** + * 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 + */ - // 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 +exports.isSupported = function isSupported (format) { + if ('number' !== typeof format) format = exports.getFormat(format); + return (binding.formats & format) === format; +} - // the `audio_output_t` struct pointer Buffer instance - this.audio_handle = null +/** + * 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 + */ - // flipped after close() is called, no write() calls allowed after - this._closed = false +function Speaker (opts) { + if (!(this instanceof Speaker)) return new Speaker(opts); - // set PCM format - this._format(opts) + // default lwm and hwm to 0 + if (!opts) opts = {}; + if (null == opts.lowWaterMark) opts.lowWaterMark = 0; + if (null == opts.highWaterMark) opts.highWaterMark = 0; - // bind event listeners - this._format = this._format.bind(this) - this.on('finish', this._flush) - this.on('pipe', this._pipe) - this.on('unpipe', this._unpipe) - } + Writable.call(this, opts); - /** - * Calls the audio backend's `open()` function, and then emits an "open" event. - * - * @api private - */ + // 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; - _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 - } + // the `audio_output_t` struct pointer Buffer instance + this.audio_handle = null; - const format = Speaker.getFormat(this) - if (format == null) { - throw new Error('invalid PCM format specified') - } + // flipped after close() is called, no write() calls allowed after + this._closed = false; - if (!Speaker.isSupported(format)) { - throw new Error(`specified PCM format is not supported by "${binding.name}" backend`) - } + // set PCM format + this._format(opts); - // calculate the "block align" - this.blockAlign = this.bitDepth / 8 * this.channels + // bind event listeners + this._format = this._format.bind(this); + this.on('finish', this._flush); + this.on('pipe', this._pipe); + this.on('unpipe', this._unpipe); +} +inherits(Speaker, Writable); - // 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) - if (r !== 0) { - throw new Error(`open() failed: ${r}`) - } +/** + * Calls the audio backend's `open()` function, and then emits an "open" event. + * + * @api private + */ - this.emit('open') - return this.audio_handle +Speaker.prototype._open = function () { + debug('open()'); + if (this.audio_handle) { + throw new Error('_open() called more than once!'); + } + // set default options, if not set + if (null == this.channels) { + debug('setting default %o: %o', 'channels', 2); + this.channels = 2; + } + if (null == this.bitDepth) { + var depth = this.float ? 32 : 16; + debug('setting default %o: %o', 'bitDepth', depth); + this.bitDepth = depth; + } + if (null == this.sampleRate) { + debug('setting default %o: %o', 'sampleRate', 44100); + this.sampleRate = 44100; + } + if (null == this.signed) { + debug('setting default %o: %o', 'signed', this.bitDepth != 8); + this.signed = this.bitDepth != 8; } - /** - * 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.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}"`)) - } + var format = exports.getFormat(this); + if (null == format) { + throw new Error('invalid PCM format specified'); } - /** - * `_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) - } + if (!exports.isSupported(format)) { + throw new Error('specified PCM format is not supported by "' + binding.name + '" backend'); + } - 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() - } - } + // calculate the "block align" + this.blockAlign = this.bitDepth / 8 * this.channels; - write() + // initialize the audio handle + // TODO: open async? + this.audio_handle = new Buffer(binding.sizeof_audio_output_t); + var r = binding.open(this.audio_handle, this.channels, this.sampleRate, format); + if (0 !== r) { + throw new Error('open() failed: ' + r); } - /** - * 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) - } + this.emit('open'); + return this.audio_handle; +}; - /** - * 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) +/** + * 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 + */ + +Speaker.prototype._format = function (opts) { + debug('format(object keys = %o)', Object.keys(opts)); + if (null != opts.channels) { + debug('setting %o: %o', 'channels', opts.channels); + this.channels = opts.channels; + } + if (null != opts.bitDepth) { + debug('setting %o: %o', "bitDepth", opts.bitDepth); + this.bitDepth = opts.bitDepth; + } + if (null != opts.sampleRate) { + debug('setting %o: %o', "sampleRate", opts.sampleRate); + this.sampleRate = opts.sampleRate; + } + if (null != opts.float) { + debug('setting %o: %o', "float", opts.float); + this.float = opts.float; } + if (null != opts.signed) { + debug('setting %o: %o', "signed", opts.signed); + this.signed = opts.signed; + } + if (null != opts.samplesPerFrame) { + debug('setting %o: %o', "samplesPerFrame", opts.samplesPerFrame); + this.samplesPerFrame = opts.samplesPerFrame; + } + if (null == opts.endianness || 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 + */ + +Speaker.prototype._write = function (chunk, encoding, done) { + debug('_write() (%o bytes)', chunk.length); - /** - * 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) + if (this._closed) { + // close() has already been called. this should not be called + return done(new Error('write() call after close() call')); + } + var b; + var self = this; + var left = chunk; + var 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); + } } + var chunkSize = this.blockAlign * this.samplesPerFrame; - /** - * 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 + function write () { + if (self._closed) { + debug('aborting remainder of write() call (%o bytes), since speaker is `_closed`', left.length); + return done(); + } + b = left; + if (b.length > chunkSize) { + var t = b; + b = t.slice(0, chunkSize); + left = t.slice(chunkSize); } else { - debug('not invoking flush() or close() bindings since no `audio_handle`') + left = null; } + debug('writing %o byte chunk', b.length); + binding.write(handle, b, b.length, onwrite); + } - this._closed = true - this.emit('close') + var THIS = this; //preserve "this" for onwrite call-back + function onwrite (r) { + debug('wrote %o bytes', r); + if (isNaN(++THIS.numwr)) THIS.numwr = 1; //track #writes; is this == #frames? + if (isNaN(THIS.wrtotal += r)) THIS.wrtotal = r; //track total data written + THIS.emit('progress', {numwr: THIS.numwr, wrlen: r, wrtotal: THIS.wrtotal, buflen: (left || []).length}); //give caller some progress info + 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(); +}; /** - * Export information about the `mpg123_module_t` being used. + * 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 */ -Speaker.api_version = binding.api_version -Speaker.description = binding.description -Speaker.module_name = binding.name +Speaker.prototype._pipe = function (source) { + debug('_pipe()'); + this._format(source); + source.once('format', this._format); +}; /** - * Returns the `MPG123_ENC_*` constant that corresponds to the given "format" - * object, or `null` if the format is invalid. + * 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. * - * @param {Object} format - format object with `channels`, `sampleRate`, `bitDepth`, etc. - * @return {Number} MPG123_ENC_* constant, or `null` - * @api public + * @api private */ -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 - } -} +Speaker.prototype._unpipe = function (source) { + debug('_unpipe()'); + source.removeListener('format', this._format); +}; /** - * Returns `true` if the given "format" is playable via the "output module" - * that was selected during compilation, or `false` if not playable. + * Emits a "flush" event and then calls the `.close()` function on + * this Speaker instance. * - * @param {Number} format - MPG123_ENC_* format constant - * @return {Boolean} true if the format is playable, false otherwise - * @api public + * @api private */ -Speaker.isSupported = function isSupported (format) { - if (typeof format !== 'number') format = Speaker.getFormat(format) - return (binding.formats & format) === format -} +Speaker.prototype._flush = function () { + debug('_flush()'); + this.emit('flush'); + this.close(false); +}; /** - * Module exports. + * 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 */ -exports = module.exports = Speaker +Speaker.prototype.close = function (flush) { + debug('close(%o)', flush); + if (this._closed) return debug('already closed...'); + + if (this.audio_handle) { + if (false !== flush) { + // 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'); +}; diff --git a/package.json b/package.json index 1bfbff0..8444574 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,6 @@ { "name": "speaker", - "version": "0.4.0", - "license": "MIT", "description": "Output PCM audio data to the speakers", - "author": "Nathan Rajlich (http://tootallnate.net)", - "repository": "TooTallNate/node-speaker", - "scripts": { - "test": "standard && node-gyp rebuild --mpg123-backend=dummy && mocha --reporter spec" - }, - "dependencies": { - "bindings": "^1.3.0", - "buffer-alloc": "^1.1.0", - "debug": "^3.0.1", - "nan": "^2.6.2", - "readable-stream": "^2.3.3" - }, - "devDependencies": { - "mocha": "^3.5.0", - "standard": "^10.0.3" - }, - "engines": { - "node": ">4" - }, "keywords": [ "pcm", "audio", @@ -39,5 +18,30 @@ "oss", "pulse", "mpg123" - ] + ], + "license": "MIT", + "version": "0.3.1", + "author": "Nathan Rajlich (http://tootallnate.net)", + "repository": { + "type": "git", + "url": "git://github.com/TooTallNate/node-speaker.git" + }, + "bugs": { + "url": "https://github.com/TooTallNate/node-speaker/issues" + }, + "homepage": "https://github.com/TooTallNate/node-speaker", + "main": "./index.js", + "scripts": { + "build-dev": "node-gyp configure build --verbose", + "test": "node-gyp rebuild --mpg123-backend=dummy && mocha --reporter spec" + }, + "dependencies": { + "bindings": "^1.2.1", + "debug": "^2.2.0", + "nan": "^2.2.0", + "readable-stream": "^2.0.5" + }, + "devDependencies": { + "mocha": "^2.1.0" + } } From 8c20c199bbf59c3b97f1f2c29da2e21ee7b12420 Mon Sep 17 00:00:00 2001 From: dj Date: Tue, 21 Nov 2017 21:23:24 -0800 Subject: [PATCH 2/6] add progress event --- README.md | 56 +++-- index.js | 587 +++++++++++++++++++++++++-------------------------- package.json | 48 ++--- 3 files changed, 338 insertions(+), 353 deletions(-) diff --git a/README.md b/README.md index d1a4a2b..f18df01 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,40 @@ -node-speaker -============ -### Output [PCM audio][pcm] data to the speakers +# node-speaker + +## Output [PCM audio][pcm] data to the speakers + [![Build Status](https://secure.travis-ci.org/TooTallNate/node-speaker.svg)](https://travis-ci.org/TooTallNate/node-speaker) [![Build Status](https://ci.appveyor.com/api/projects/status/wix7wml3v55670kw?svg=true)](https://ci.appveyor.com/project/TooTallNate/node-speaker) - A Writable stream instance that accepts [PCM audio][pcm] data and outputs it to the speakers. The output is backed by `mpg123`'s audio output modules, which in turn use any number of audio backends commonly found on Operating Systems these days. - -Installation ------------- +## Installation Simply compile and install `node-speaker` using `npm`: -``` bash -$ npm install speaker +```sh +npm install speaker ``` On Debian/Ubuntu, the [ALSA][alsa] backend is selected by default, so be sure to have the `alsa.h` header file in place: -``` bash -$ sudo apt-get install libasound2-dev +```sh +sudo apt-get install libasound2-dev ``` - -Example -------- +## Example Here's an example of piping `stdin` to the speaker, which should be 2 channel, 16-bit audio at 44,100 samples per second (a.k.a CD quality audio). -``` javascript -var Speaker = require('speaker'); +```javascript +const Speaker = require('speaker'); // Create the Speaker instance -var speaker = new Speaker({ +const speaker = new Speaker({ channels: 2, // 2 channels bitDepth: 16, // 16-bit samples sampleRate: 44100 // 44,100 Hz sample rate @@ -48,25 +44,23 @@ var speaker = new Speaker({ process.stdin.pipe(speaker); ``` - -API ---- +## API `require('speaker')` directly returns the `Speaker` constructor. It is the only interface exported by `node-speaker`. -### new Speaker([ format ]) -> Speaker instance; +### new Speaker([ format ]) -> Speaker instance Creates a new `Speaker` instance, which is a writable stream that you can pipe PCM audio data to. The optional `format` object may contain any of the `Writable` base class options, as well as any of these PCM formatting options: - * `channels` - The number of audio channels. PCM data must be interleaved. Defaults to `2`. - * `bitDepth` - The number of bits per sample. Defaults to `16` (16-bit). - * `sampleRate` - The number of samples per second per channel. Defaults to `44100`. - * `signed` - Boolean specifying if the samples are signed or unsigned. Defaults to `true` when bit depth is 8-bit, `false` otherwise. - * `float` - Boolean specifying if the samples are floating-point values. Defaults to `false`. - * `samplesPerFrame` - The number of samples to send to the audio backend at a time. You likely don't need to mess with this value. Defaults to `1024`. +* `channels` - The number of audio channels. PCM data must be interleaved. Defaults to `2`. +* `bitDepth` - The number of bits per sample. Defaults to `16` (16-bit). +* `sampleRate` - The number of samples per second per channel. Defaults to `44100`. +* `signed` - Boolean specifying if the samples are signed or unsigned. Defaults to `true` when bit depth is 8-bit, `false` otherwise. +* `float` - Boolean specifying if the samples are floating-point values. Defaults to `false`. +* `samplesPerFrame` - The number of samples to send to the audio backend at a time. You likely don't need to mess with this value. Defaults to `1024`. #### "open" event @@ -91,9 +85,7 @@ has been flushed to the speakers. Fired after the "flush" event, after the backend `close()` call has completed. This speaker instance is essentially finished after this point. - -Audio Backend Selection ------------------------ +## Audio Backend Selection `node-speaker` is backed by `mpg123`'s "output modules", which in turn use one of many popular audio backends like ALSA, OSS, SDL, and lots more. The default @@ -109,8 +101,8 @@ backends for each operating system are described in the table below: To manually override the default backend, pass the `--mpg123-backend` switch to `npm`/`node-gyp`: -``` bash -$ npm install speaker --mpg123-backend=openal +```sh +npm install speaker --mpg123-backend=openal ``` [pcm]: http://en.wikipedia.org/wiki/Pulse-code_modulation diff --git a/index.js b/index.js index 6b8dc69..ba9f21a 100644 --- a/index.js +++ b/index.js @@ -1,81 +1,17 @@ +'use strict' /** * Module dependencies. */ -var os = require('os'); -var debug = require('debug')('speaker'); -var binding = require('bindings')('binding'); -var inherits = require('util').inherits; -var Writable = require('readable-stream/writable'); +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 -var endianness = 'function' == os.endianness ? - os.endianness() : - 'LE'; // assume little-endian for older versions of node.js - -/** - * Module exports. - */ - -exports = module.exports = Speaker; - -/** - * Export information about the `mpg123_module_t` being used. - */ - -exports.api_version = binding.api_version; -exports.description = binding.description; -exports.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 - */ - -exports.getFormat = function getFormat (format) { - var f = null; - if (format.bitDepth == 32 && format.float && format.signed) { - f = binding.MPG123_ENC_FLOAT_32; - } else if (format.bitDepth == 64 && format.float && format.signed) { - f = binding.MPG123_ENC_FLOAT_64; - } else if (format.bitDepth == 8 && format.signed) { - f = binding.MPG123_ENC_SIGNED_8; - } else if (format.bitDepth == 8 && !format.signed) { - f = binding.MPG123_ENC_UNSIGNED_8; - } else if (format.bitDepth == 16 && format.signed) { - f = binding.MPG123_ENC_SIGNED_16; - } else if (format.bitDepth == 16 && !format.signed) { - f = binding.MPG123_ENC_UNSIGNED_16; - } else if (format.bitDepth == 24 && format.signed) { - f = binding.MPG123_ENC_SIGNED_24; - } else if (format.bitDepth == 24 && !format.signed) { - f = binding.MPG123_ENC_UNSIGNED_24; - } else if (format.bitDepth == 32 && format.signed) { - f = binding.MPG123_ENC_SIGNED_32; - } else if (format.bitDepth == 32 && !format.signed) { - f = binding.MPG123_ENC_UNSIGNED_32; - } - return f; -} - -/** - * 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 - */ - -exports.isSupported = function isSupported (format) { - if ('number' !== typeof format) format = exports.getFormat(format); - return (binding.formats & format) === format; -} +const endianness = os.endianness() /** * The `Speaker` class accepts raw PCM data written to it, and then sends that data @@ -85,272 +21,333 @@ exports.isSupported = function isSupported (format) { * @api public */ -function Speaker (opts) { - if (!(this instanceof Speaker)) return new Speaker(opts); +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 - // default lwm and hwm to 0 - if (!opts) opts = {}; - if (null == opts.lowWaterMark) opts.lowWaterMark = 0; - if (null == opts.highWaterMark) opts.highWaterMark = 0; + super(opts) - Writable.call(this, 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 - // 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 - // 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 - // flipped after close() is called, no write() calls allowed after - this._closed = false; + // set PCM format + this._format(opts) - // 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); -} -inherits(Speaker, Writable); - -/** - * Calls the audio backend's `open()` function, and then emits an "open" event. - * - * @api private - */ - -Speaker.prototype._open = function () { - debug('open()'); - if (this.audio_handle) { - throw new Error('_open() called more than once!'); - } - // set default options, if not set - if (null == this.channels) { - debug('setting default %o: %o', 'channels', 2); - this.channels = 2; - } - if (null == this.bitDepth) { - var depth = this.float ? 32 : 16; - debug('setting default %o: %o', 'bitDepth', depth); - this.bitDepth = depth; - } - if (null == this.sampleRate) { - debug('setting default %o: %o', 'sampleRate', 44100); - this.sampleRate = 44100; - } - if (null == this.signed) { - debug('setting default %o: %o', 'signed', this.bitDepth != 8); - this.signed = this.bitDepth != 8; + // bind event listeners + this._format = this._format.bind(this) + this.on('finish', this._flush) + this.on('pipe', this._pipe) + this.on('unpipe', this._unpipe) } - var format = exports.getFormat(this); - if (null == format) { - throw new Error('invalid PCM format specified'); - } + /** + * Calls the audio backend's `open()` function, and then emits an "open" event. + * + * @api private + */ - if (!exports.isSupported(format)) { - throw new Error('specified PCM format is not supported by "' + binding.name + '" backend'); - } + _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 + } - // calculate the "block align" - this.blockAlign = this.bitDepth / 8 * this.channels; + const format = Speaker.getFormat(this) + if (format == null) { + throw new Error('invalid PCM format specified') + } - // initialize the audio handle - // TODO: open async? - this.audio_handle = new Buffer(binding.sizeof_audio_output_t); - var r = binding.open(this.audio_handle, this.channels, this.sampleRate, format); - if (0 !== r) { - throw new Error('open() failed: ' + r); - } + if (!Speaker.isSupported(format)) { + throw new Error(`specified PCM format is not supported by "${binding.name}" backend`) + } - this.emit('open'); - return this.audio_handle; -}; + // calculate the "block align" + this.blockAlign = this.bitDepth / 8 * this.channels -/** - * 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 - */ + // 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) + if (r !== 0) { + throw new Error(`open() failed: ${r}`) + } -Speaker.prototype._format = function (opts) { - debug('format(object keys = %o)', Object.keys(opts)); - if (null != opts.channels) { - debug('setting %o: %o', 'channels', opts.channels); - this.channels = opts.channels; + this.emit('open') + return this.audio_handle } - if (null != opts.bitDepth) { - debug('setting %o: %o', "bitDepth", opts.bitDepth); - this.bitDepth = opts.bitDepth; - } - if (null != opts.sampleRate) { - debug('setting %o: %o', "sampleRate", opts.sampleRate); - this.sampleRate = opts.sampleRate; - } - if (null != opts.float) { - debug('setting %o: %o', "float", opts.float); - this.float = opts.float; - } - if (null != opts.signed) { - debug('setting %o: %o', "signed", opts.signed); - this.signed = opts.signed; - } - if (null != opts.samplesPerFrame) { - debug('setting %o: %o', "samplesPerFrame", opts.samplesPerFrame); - this.samplesPerFrame = opts.samplesPerFrame; - } - if (null == opts.endianness || 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 + '"')); + + /** + * 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.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()` 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) + } + + var THIS = this; //preserve "this" for onwrite call-back + const onwrite = (r) => { + debug('wrote %o bytes', r); + if (isNaN(++THIS.numwr)) THIS.numwr = 1; //track #writes; is this == #frames? + if (isNaN(THIS.wrtotal += r)) THIS.wrtotal = r; //track total data written + THIS.emit('progress', {numwr: THIS.numwr, wrlen: r, wrtotal: THIS.wrtotal, buflen: (left || []).length}); //give caller some progress info + 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() + } + } -Speaker.prototype._write = function (chunk, encoding, done) { - debug('_write() (%o bytes)', chunk.length); + write() + } - if (this._closed) { - // close() has already been called. this should not be called - return done(new Error('write() call after close() call')); + /** + * 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) } - var b; - var self = this; - var left = chunk; - var 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); - } + + /** + * 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) } - var chunkSize = this.blockAlign * this.samplesPerFrame; - function write () { - if (self._closed) { - debug('aborting remainder of write() call (%o bytes), since speaker is `_closed`', left.length); - return done(); - } - b = left; - if (b.length > chunkSize) { - var 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); + /** + * 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) } - var THIS = this; //preserve "this" for onwrite call-back - function onwrite (r) { - debug('wrote %o bytes', r); - if (isNaN(++THIS.numwr)) THIS.numwr = 1; //track #writes; is this == #frames? - if (isNaN(THIS.wrtotal += r)) THIS.wrtotal = r; //track total data written - THIS.emit('progress', {numwr: THIS.numwr, wrlen: r, wrtotal: THIS.wrtotal, buflen: (left || []).length}); //give caller some progress info - if (r != b.length) { - done(new Error('write() failed: ' + r)); - } else if (left) { - debug('still %o bytes left in this chunk', left.length); - write(); + /** + * 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('done with this chunk'); - done(); + debug('not invoking flush() or close() bindings since no `audio_handle`') } - } - write(); -}; + this._closed = true + this.emit('close') + } +} /** - * 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 + * Export information about the `mpg123_module_t` being used. */ -Speaker.prototype._pipe = function (source) { - debug('_pipe()'); - this._format(source); - source.once('format', this._format); -}; +Speaker.api_version = binding.api_version +Speaker.description = binding.description +Speaker.module_name = binding.name /** - * 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. + * Returns the `MPG123_ENC_*` constant that corresponds to the given "format" + * object, or `null` if the format is invalid. * - * @api private + * @param {Object} format - format object with `channels`, `sampleRate`, `bitDepth`, etc. + * @return {Number} MPG123_ENC_* constant, or `null` + * @api public */ -Speaker.prototype._unpipe = function (source) { - debug('_unpipe()'); - source.removeListener('format', this._format); -}; +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 + } +} /** - * Emits a "flush" event and then calls the `.close()` function on - * this Speaker instance. + * Returns `true` if the given "format" is playable via the "output module" + * that was selected during compilation, or `false` if not playable. * - * @api private + * @param {Number} format - MPG123_ENC_* format constant + * @return {Boolean} true if the format is playable, false otherwise + * @api public */ -Speaker.prototype._flush = function () { - debug('_flush()'); - this.emit('flush'); - this.close(false); -}; +Speaker.isSupported = function isSupported (format) { + if (typeof format !== 'number') format = Speaker.getFormat(format) + return (binding.formats & format) === format +} /** - * 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 + * Module exports. */ -Speaker.prototype.close = function (flush) { - debug('close(%o)', flush); - if (this._closed) return debug('already closed...'); - - if (this.audio_handle) { - if (false !== flush) { - // 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'); -}; +exports = module.exports = Speaker diff --git a/package.json b/package.json index 8444574..58c2956 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,27 @@ { "name": "speaker", + "version": "0.4.1", + "license": "MIT", "description": "Output PCM audio data to the speakers", + "author": "Nathan Rajlich (http://tootallnate.net)", + "repository": "TooTallNate/node-speaker", + "scripts": { + "test": "standard && node-gyp rebuild --mpg123-backend=dummy && mocha --reporter spec" + }, + "dependencies": { + "bindings": "^1.3.0", + "buffer-alloc": "^1.1.0", + "debug": "^3.0.1", + "nan": "^2.6.2", + "readable-stream": "^2.3.3" + }, + "devDependencies": { + "mocha": "^3.5.0", + "standard": "^10.0.3" + }, + "engines": { + "node": ">4" + }, "keywords": [ "pcm", "audio", @@ -18,30 +39,5 @@ "oss", "pulse", "mpg123" - ], - "license": "MIT", - "version": "0.3.1", - "author": "Nathan Rajlich (http://tootallnate.net)", - "repository": { - "type": "git", - "url": "git://github.com/TooTallNate/node-speaker.git" - }, - "bugs": { - "url": "https://github.com/TooTallNate/node-speaker/issues" - }, - "homepage": "https://github.com/TooTallNate/node-speaker", - "main": "./index.js", - "scripts": { - "build-dev": "node-gyp configure build --verbose", - "test": "node-gyp rebuild --mpg123-backend=dummy && mocha --reporter spec" - }, - "dependencies": { - "bindings": "^1.2.1", - "debug": "^2.2.0", - "nan": "^2.2.0", - "readable-stream": "^2.0.5" - }, - "devDependencies": { - "mocha": "^2.1.0" - } + ] } From b2b893581a36da965179d3cef633f3b3d6f00e8f Mon Sep 17 00:00:00 2001 From: djulien Date: Sat, 1 Dec 2018 00:51:48 -0800 Subject: [PATCH 3/6] pull in recent changes from TooTallNate's version https://github.com/TooTallNate/node-speaker --- README.md | 5 +++-- index.d.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.js | 10 ++++++++- package.json | 3 +++ src/binding.cc | 9 ++++++++- test/test.js | 9 +++++++++ 6 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 index.d.ts diff --git a/README.md b/README.md index f18df01..6100633 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ process.stdin.pipe(speaker); `require('speaker')` directly returns the `Speaker` constructor. It is the only interface exported by `node-speaker`. -### new Speaker([ format ]) -> Speaker instance +### new Speaker([ options ]) -> Speaker instance Creates a new `Speaker` instance, which is a writable stream that you can pipe -PCM audio data to. The optional `format` object may contain any of the `Writable` +PCM audio data to. The optional `options` object may contain any of the `Writable` base class options, as well as any of these PCM formatting options: * `channels` - The number of audio channels. PCM data must be interleaved. Defaults to `2`. @@ -61,6 +61,7 @@ base class options, as well as any of these PCM formatting options: * `signed` - Boolean specifying if the samples are signed or unsigned. Defaults to `true` when bit depth is 8-bit, `false` otherwise. * `float` - Boolean specifying if the samples are floating-point values. Defaults to `false`. * `samplesPerFrame` - The number of samples to send to the audio backend at a time. You likely don't need to mess with this value. Defaults to `1024`. +* `device` - The name of the playback device. E.g. `'hw:0,0'` for first device of first sound card or `'hw:1,0'` for first device of second sound card. Defaults to `null` which will pick the default device. #### "open" event diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..ff786f2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,55 @@ +import { Writable, WritableOptions } from 'stream'; + +export interface Options extends WritableOptions { + readonly channels?: number; + readonly bitDepth?: number; + readonly sampleRate?: number; + readonly lowWaterMark?: number; + readonly highWaterMark?: number; +} + +export interface Format { + readonly float?: boolean; + readonly signed?: boolean; + readonly bitDepth?: number; + readonly channels?: number; + readonly sampleRate?: number; + readonly samplesPerFrame?: number; +} + +/** + * The `Speaker` class accepts raw PCM data written to it, and then sends that data + * to the default output device of the OS. + * + * @param opts options. + */ +export default class Speaker extends Writable { + constructor(opts?: Options); + + /** + * 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 flush Defaults to `true`. + */ + public close(flush: boolean): string; + + /** + * Returns the `MPG123_ENC_*` constant that corresponds to the given "format" + * object, or `null` if the format is invalid. + * + * @param format format object with `channels`, `sampleRate`, `bitDepth`, etc. + * @return MPG123_ENC_* constant, or `null` + */ + public getFormat(format: Format): number | null; + + /** + * Returns whether or not "format" is playable via the "output module" + * that was selected during compilation. + * + * @param format MPG123_ENC_* format constant + * @return whether or not is playable + */ + public isSupported(format: number): boolean; +} diff --git a/index.js b/index.js index ba9f21a..712f11e 100644 --- a/index.js +++ b/index.js @@ -81,6 +81,10 @@ class Speaker extends Writable { 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) { @@ -97,7 +101,7 @@ class Speaker extends Writable { // 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) + const r = binding.open(this.audio_handle, this.channels, this.sampleRate, format, this.device) if (r !== 0) { throw new Error(`open() failed: ${r}`) } @@ -141,6 +145,10 @@ class Speaker extends Writable { 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 diff --git a/package.json b/package.json index 58c2956..4fbd746 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "description": "Output PCM audio data to the speakers", "author": "Nathan Rajlich (http://tootallnate.net)", "repository": "TooTallNate/node-speaker", + "main": "index.js", + "types": "index.d.ts", "scripts": { "test": "standard && node-gyp rebuild --mpg123-backend=dummy && mocha --reporter spec" }, @@ -16,6 +18,7 @@ "readable-stream": "^2.3.3" }, "devDependencies": { + "@types/node": "^10.11.4", "mocha": "^3.5.0", "standard": "^10.0.3" }, diff --git a/src/binding.cc b/src/binding.cc index edc068b..dd98228 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -31,6 +31,12 @@ NAN_METHOD(Open) { ao->rate = info[2]->Int32Value(); /* sample rate */ ao->format = info[3]->Int32Value(); /* MPG123_ENC_* format */ + if (info[4]->IsString()) { + v8::Local deviceString = info[4]->ToString(); + ao->device = new char[deviceString->Length() + 1]; + deviceString->WriteOneByte(reinterpret_cast(ao->device)); + } + /* init_output() */ r = mpg123_output_module_info.init_output(ao); if (r == 0) { @@ -59,7 +65,7 @@ NAN_METHOD(Write) { req->req.data = req; - uv_queue_work(uv_default_loop(), &req->req, write_async, (uv_after_work_cb)write_after); + uv_queue_work(Nan::GetCurrentEventLoop(), &req->req, write_async, (uv_after_work_cb)write_after); info.GetReturnValue().SetUndefined(); } @@ -98,6 +104,7 @@ NAN_METHOD(Close) { if (ao->deinit) { r = ao->deinit(ao); } + delete ao->device; info.GetReturnValue().Set(scope.Escape(Nan::New(r))); } diff --git a/test/test.js b/test/test.js index 6e7a94f..1154886 100644 --- a/test/test.js +++ b/test/test.js @@ -95,6 +95,15 @@ describe('Speaker', function () { done() }) + it('should accept a device option', function (done) { + const s = new Speaker({ device: 'test' }) + + assert.equal(s.device, 'test') + + s.on('close', done) + s.end(bufferAlloc(0)) + }) + it('should not throw an Error if native "endianness" is specified', function () { assert.doesNotThrow(function () { // eslint-disable-next-line no-new From ef0e8de52647e2aba690b1d99dfc1ab10e6fe1fd Mon Sep 17 00:00:00 2001 From: djulien Date: Sun, 16 Dec 2018 00:31:24 -0800 Subject: [PATCH 4/6] reduce latency: add sync progress data, remove async event emit --- index.js | 31 ++++++++++-- package.json | 2 +- src/binding.cc | 130 +++++++++++++++++++++++++++++++++++++++++-------- topi.sh | 46 +++++++++++++++++ 4 files changed, 184 insertions(+), 25 deletions(-) create mode 100755 topi.sh diff --git a/index.js b/index.js index 712f11e..950a4d6 100644 --- a/index.js +++ b/index.js @@ -204,12 +204,13 @@ class Speaker extends Writable { binding.write(handle, b, b.length, onwrite) } - var THIS = this; //preserve "this" for onwrite call-back +// var THIS = this; //preserve "this" for onwrite call-back; arrow functions don't have a "this" const onwrite = (r) => { debug('wrote %o bytes', r); - if (isNaN(++THIS.numwr)) THIS.numwr = 1; //track #writes; is this == #frames? - if (isNaN(THIS.wrtotal += r)) THIS.wrtotal = r; //track total data written - THIS.emit('progress', {numwr: THIS.numwr, wrlen: r, wrtotal: THIS.wrtotal, buflen: (left || []).length}); //give caller some progress info +// removed -dj 12/15/18; too much latency (and too unpredictable due to node event loop) +// if (isNaN(++THIS.numwr)) THIS.numwr = 1; //track #writes; is this == #frames? +// if (isNaN(THIS.wrtotal += r)) THIS.wrtotal = r; //track total data written +// THIS.emit('progress', {numwr: THIS.numwr, wrlen: r, wrtotal: THIS.wrtotal, buflen: (left || []).length}); //give caller some progress info if (r !== b.length) { done(new Error(`write() failed: ${r}`)) } else if (left) { @@ -224,6 +225,28 @@ class Speaker extends Writable { write() } + + /** + * get current playback progress. -dj 12/15/18 + * Last couple of writes can be used to estimate current playback status. + * There will always be latency and hence uncertainty, but try to keep to minimum. + * Async event emit would introduce more (variable) latency due to uv event loop, + * so just let caller ask for progress data synchronously. + * + * @api public + */ + progress () { + debug('status()') +// if (this._closed) return debug('already closed...') + if (this._closed) return debug('already closed...') + if (!this.audio_handle) return debug('no handle, closed?'); + debug('invoking progess() native binding') + const retval = binding.progess(this.audio_handle); + debug('got back %o from progess()', retval) + return retval + } + + /** * Called when this stream is pipe()d to from another readable stream. * If the "sampleRate", "channels", "bitDepth", and "signed" properties are diff --git a/package.json b/package.json index 4fbd746..bf3909f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speaker", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "description": "Output PCM audio data to the speakers", "author": "Nathan Rajlich (http://tootallnate.net)", diff --git a/src/binding.cc b/src/binding.cc index dd98228..8d84ae2 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -1,6 +1,8 @@ #include #include #include +#include //now(), duration<> +#include //std::map<> #include "node_pointer.h" #include "output.h" @@ -12,6 +14,52 @@ extern mpg123_module_t mpg123_output_module_info; namespace { + +//helper macros: +//#define TOSTR_NESTED(str) #str //kludge: need nested macro for this to work +//#define TOSTR(str) TOSTR_NESTED(str) +#define HERE(n) printf("here# " #n " @%d\n", __LINE__) + + +//using Now = std::chrono::high_resolution_clock::now(); +auto now() { return std::chrono::high_resolution_clock::now(); } //use high res clock for more accurate progress -dj 12/15/18 + +//add state info to audio_output struct: -dj 12/15/18 +typedef struct audio_output_MODIFIED_struct +{ + int numwr; //#writes queued + long wrtotal; //total bytes written +// long timestamp; //theoretical presentation time based on data samples + decltype(now()) enque, start, finish, epoch; //= Now(); //system time of last write (enqueue, start, finish), current time + audio_output_t original; //original untouched struct; put at end in case caller extends it +//methods: + double elapsed_usec(decltype(now())& when) + { +//examples at https://stackoverflow.com/questions/14391327/how-to-get-duration-as-int-millis-and-float-seconds-from-chrono + /*std::chrono::duration*/ auto diff = when - epoch; + return std::chrono::duration_cast(diff).count(); + } + static int SampleSize(int fmt) + { + static const std::map size_map = //map sample type => size + { + {MPG123_ENC_FLOAT_32, 32}, + {MPG123_ENC_FLOAT_64, 64}, + {MPG123_ENC_SIGNED_8, 8}, + {MPG123_ENC_UNSIGNED_8, 8}, + {MPG123_ENC_SIGNED_16, 16}, + {MPG123_ENC_UNSIGNED_16, 16}, + {MPG123_ENC_SIGNED_24, 24}, + {MPG123_ENC_UNSIGNED_24, 24}, + {MPG123_ENC_SIGNED_32, 32}, + {MPG123_ENC_UNSIGNED_32, 32}, + }; + return size_map.count(fmt)? size_map.find(fmt)->second / 8: 0; + } +} audio_output_MODIFIED_t; +#define audio_output_t audio_output_MODIFIED_t //use wedge to minimize source code changes + + struct write_req { uv_work_t req; audio_output_t *ao; @@ -21,27 +69,30 @@ struct write_req { Nan::Callback *callback; }; + NAN_METHOD(Open) { Nan::EscapableHandleScope scope; int r; audio_output_t *ao = UnwrapPointer(info[0]); memset(ao, 0, sizeof(audio_output_t)); - ao->channels = info[1]->Int32Value(); /* channels */ - ao->rate = info[2]->Int32Value(); /* sample rate */ - ao->format = info[3]->Int32Value(); /* MPG123_ENC_* format */ + ao->original.channels = info[1]->Int32Value(); /* channels */ + ao->original.rate = info[2]->Int32Value(); /* sample rate */ + ao->original.format = info[3]->Int32Value(); /* MPG123_ENC_* format */ + ao->epoch = now(); //remember init time -dj 12/15/18 if (info[4]->IsString()) { v8::Local deviceString = info[4]->ToString(); - ao->device = new char[deviceString->Length() + 1]; - deviceString->WriteOneByte(reinterpret_cast(ao->device)); + ao->original.device = new char[deviceString->Length() + 1]; + deviceString->WriteOneByte(reinterpret_cast(ao->original.device)); } /* init_output() */ - r = mpg123_output_module_info.init_output(ao); + if (!ao->SampleSize(ao->original.format)) r = -12345; //throw "Unknown sample size"; //check if sample fmt is handled -dj 12/15/18 + else r = mpg123_output_module_info.init_output(&ao->original); if (r == 0) { /* open() */ - r = ao->open(ao); + r = ao->original.open(&ao->original); } info.GetReturnValue().Set(scope.Escape(Nan::New(r))); @@ -62,6 +113,7 @@ NAN_METHOD(Write) { req->len = len; req->written = 0; req->callback = new Nan::Callback(info[3].As()); + ao->enque = now(); //remember when write was queued (trying to measure latency) -dj 12/15/18 req->req.data = req; @@ -72,12 +124,17 @@ NAN_METHOD(Write) { void write_async (uv_work_t *req) { write_req *wreq = reinterpret_cast(req->data); - wreq->written = wreq->ao->write(wreq->ao, wreq->buffer, wreq->len); + wreq->written = wreq->ao->original.write(&wreq->ao->original, wreq->buffer, wreq->len); +//update progress data: -dj 12/15/18 + ++wreq->ao->numwr; //#writes queued + wreq->ao->wrtotal += wreq->len; //total bytes written + wreq->ao->start = now(); //system time of last write started } void write_after (uv_work_t *req) { Nan::HandleScope scope; write_req *wreq = reinterpret_cast(req->data); + wreq->ao->finish = now(); //system time of last write completed -dj 12/15/18 Local argv[] = { Nan::New(wreq->written) @@ -88,23 +145,54 @@ void write_after (uv_work_t *req) { delete wreq->callback; } + +//added method to get playback status -dj 12/15/18 +//caller can call this at precise intervals rather than receiving inprecise emitting async events +NAN_METHOD(Progress) { + Nan::HandleScope scope; + audio_output_t *ao = UnwrapPointer(info[0]); +//return progress info -dj 12/15/18 +//NOTE: info is based on queued writes, but there will be latency anyway; works okay if consistent/predictable + v8::Local retval = Nan::New(); +// retval->Set(Nan::New("numwr").ToLocalChecked(), info[0]->ToString()); +// HERE(2); +// printf("&ao %p, &numwr %p @%d\n", ao, &ao->numwr, __LINE__); +// printf("numwr %d, wrtotal %d @%d\n", ao->numwr, ao->wrtotal, __LINE__); + Nan::ForceSet(retval, Nan::New("numwr").ToLocalChecked(), Nan::New(ao->numwr)); //, static_cast(ReadOnly|DontDelete)); + Nan::ForceSet(retval, Nan::New("wrtotal_bytes").ToLocalChecked(), Nan::New(ao->wrtotal)); //, static_cast(ReadOnly|DontDelete)); +//get theoretical presentation time based on data samples: + long timestamp_msec = 1000L * ao->wrtotal / ao->original.channels / ao->original.rate / ao->SampleSize(ao->original.format); + Nan::ForceSet(retval, Nan::New("timestamp_msec").ToLocalChecked(), Nan::New(timestamp_msec)); //, static_cast(ReadOnly|DontDelete)); +// Nan::ForceSet(retval, Nan::New("now").ToLocalChecked(), Nan::New(ao->now)); //, static_cast(ReadOnly|DontDelete)); +//caller might have a different concept of system time, so return relative times: +//for disambiguation suggestions, see https://github.com/nodejs/nan/issues/233 + Nan::ForceSet(retval, Nan::New("enque_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(ao->enque))); + Nan::ForceSet(retval, Nan::New("start_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(ao->start))); + Nan::ForceSet(retval, Nan::New("finish_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(ao->finish))); + auto latest = now(); + Nan::ForceSet(retval, Nan::New("now_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(latest))); + info.GetReturnValue().Set(retval); + ao->epoch = latest; +} + + NAN_METHOD(Flush) { Nan::HandleScope scope; audio_output_t *ao = UnwrapPointer(info[0]); /* TODO: async */ - ao->flush(ao); + ao->original.flush(&ao->original); info.GetReturnValue().SetUndefined(); } NAN_METHOD(Close) { Nan::EscapableHandleScope scope; audio_output_t *ao = UnwrapPointer(info[0]); - ao->close(ao); + ao->original.close(&ao->original); int r = 0; - if (ao->deinit) { - r = ao->deinit(ao); + if (ao->original.deinit) { + r = ao->original.deinit(&ao->original); } - delete ao->device; + delete ao->original.device; info.GetReturnValue().Set(scope.Escape(Nan::New(r))); } @@ -125,13 +213,14 @@ void Initialize(Handle target) { audio_output_t ao; memset(&ao, 0, sizeof(audio_output_t)); - mpg123_output_module_info.init_output(&ao); - ao.channels = 2; - ao.rate = 44100; - ao.format = MPG123_ENC_SIGNED_16; - ao.open(&ao); - Nan::ForceSet(target, Nan::New("formats").ToLocalChecked(), Nan::New(ao.get_formats(&ao))); - ao.close(&ao); + mpg123_output_module_info.init_output(&ao.original); + ao.original.channels = 2; + ao.original.rate = 44100; + ao.original.format = MPG123_ENC_SIGNED_16; + ao.original.open(&ao.original); + Nan::ForceSet(target, Nan::New("formats").ToLocalChecked(), Nan::New(ao.original.get_formats(&ao.original))); + ao.original.close(&ao.original); + ao.epoch = now(); //remember init time -dj 12/15/18 target->Set(Nan::New("sizeof_audio_output_t").ToLocalChecked(), Nan::New(static_cast(sizeof(audio_output_t)))); @@ -155,6 +244,7 @@ void Initialize(Handle target) { Nan::SetMethod(target, "write", Write); Nan::SetMethod(target, "flush", Flush); Nan::SetMethod(target, "close", Close); + Nan::SetMethod(target, "progess", Progress); //added -dj 12/15/18 } } // anonymous namespace diff --git a/topi.sh b/topi.sh new file mode 100755 index 0000000..e8d1f21 --- /dev/null +++ b/topi.sh @@ -0,0 +1,46 @@ +#!/bin/bash +#send files to RPi +#set -x + +#HERE="$(dirname "$(readlink -fm "$0")")" #https://stackoverflow.com/questions/20196034/retrieve-parent-directory-of-script +#MY_TOP=`git rev-parse --show-toplevel` #from https://unix.stackexchange.com/questions/6463/find-searching-in-parent-directories-instead-of-subdirectories +#source "$MY_TOP"/scripts/colors.sh +#source "$HERE"/colors.sh +#from http://stackoverflow.com/questions/5947742/how-to-change-the-output-color-of-echo-in-linux +RED='\e[1;31m' #too dark: '\e[0;31m' #`tput setaf 1` +GREEN='\e[1;32m' #`tput setaf 2` +YELLOW='\e[1;33m' #`tput setaf 3` +BLUE='\e[1;34m' #`tput setaf 4` +PINK='\e[1;35m' #`tput setaf 5` +CYAN='\e[1;36m' #`tput setaf 6` +GRAY='\e[0;37m' +NORMAL='\e[0m' #`tput sgr0` + +DEST="$1/node_modules/speaker" +SRC="." + +set -x +cp "${SRC}/build/Release/binding.node" "${DEST}/build/Release/" +cp "${SRC}/index.js" "${DEST}/" + +exit 1 +#echo "i am at $HERE" +#echo use script "$HERE"/getcfg.js +#echo -e "${BLUE}running $HERE/getcfg.js${NORMAL}" +echo -e "${BLUE}setting vars${NORMAL}" +"$HERE"/getcfg.js +eval $("$HERE"/getcfg.js) + +if [ $# -lt 1 ]; then + echo -e "${RED}no files to transfer?${NORMAL}" +else + for file in "$@" + do + echo -e "${BLUE}xfr ${CYAN}$file ${BLUE}at `date`${NORMAL}" +# echo sshpass -p $RPi_pass scp "$file" $RPi_user@$RPi_addr:$RPi_folder + sshpass -p $RPi_pass scp "$file" $RPi_user@$RPi_addr:$RPi_folder + done + echo -e "${GREEN}$# files xfred.${NORMAL}" +fi + +#eof# From 8d2492243ec2b177123c1ddb4ab60194f93c2bd3 Mon Sep 17 00:00:00 2001 From: djulien Date: Mon, 17 Dec 2018 00:52:26 -0800 Subject: [PATCH 5/6] try to add volume (!worky yet) --- index.js | 35 ++++++++++++++++++++++++++++++++++- src/binding.cc | 42 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 950a4d6..5a38f63 100644 --- a/index.js +++ b/index.js @@ -237,7 +237,6 @@ class Speaker extends Writable { */ progress () { debug('status()') -// if (this._closed) return debug('already closed...') if (this._closed) return debug('already closed...') if (!this.audio_handle) return debug('no handle, closed?'); debug('invoking progess() native binding') @@ -247,6 +246,40 @@ class Speaker extends Writable { } + /** + * get current playback volume. -dj 12/15/18 + * Not sure which value to use (base, actual, rva_db), so get them all. + * + * @api public + */ + get_vol () { + debug('get_vol()') + if (this._closed) return debug('already closed...') + if (!this.audio_handle) return debug('no handle, closed?'); + debug('invoking get_vol() native binding') + const retval = binding.volume_get(this.audio_handle); + debug('got back %o from volume_get()', retval) + return retval + } + + + /** + * set current playback volume. -dj 12/17/18 + * + * @api public + * @param {new_vol} float + */ + set_vol (new_vol) { + debug('set_vol() %o new volume', new_vol) + if (this._closed) return debug('already closed...') + if (!this.audio_handle) return debug('no handle, closed?'); + debug('invoking set_vol() native binding') + const retval = binding.volume_set(this.audio_handle, new_vol); + debug('got back %o from volume_set()', retval) + return retval + } + + /** * Called when this stream is pipe()d to from another readable stream. * If the "sampleRate", "channels", "bitDepth", and "signed" properties are diff --git a/src/binding.cc b/src/binding.cc index 8d84ae2..34bd3c5 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -148,11 +148,10 @@ void write_after (uv_work_t *req) { //added method to get playback status -dj 12/15/18 //caller can call this at precise intervals rather than receiving inprecise emitting async events +//NOTE: info is based on queued writes, but there will be latency anyway; works okay if consistent/predictable NAN_METHOD(Progress) { Nan::HandleScope scope; audio_output_t *ao = UnwrapPointer(info[0]); -//return progress info -dj 12/15/18 -//NOTE: info is based on queued writes, but there will be latency anyway; works okay if consistent/predictable v8::Local retval = Nan::New(); // retval->Set(Nan::New("numwr").ToLocalChecked(), info[0]->ToString()); // HERE(2); @@ -176,6 +175,38 @@ NAN_METHOD(Progress) { } +//TODO: broken: #define WANT_VOLUME +#ifdef WANT_VOLUME +//added methods to get/set volume -dj 12/17/18 +//not sure if stream needs to be paused to call this +//api docs at https://www.mpg123.de/api/group__mpg123__voleq.shtml +NAN_METHOD(VolumeGet) { + Nan::HandleScope scope; + audio_output_t *ao = UnwrapPointer(info[0]); + double base, really, rva_db; + int r = mpg123_get_volume(&ao->original, &base, &really, &rva_db); + if (r) { info.GetReturnValue().Set(scope.Escape(Nan::New(r))); return; } //error + v8::Local retval = Nan::New(); + Nan::ForceSet(retval, Nan::New("base").ToLocalChecked(), Nan::New(base)); + Nan::ForceSet(retval, Nan::New("actual").ToLocalChecked(), Nan::New(really)); + Nan::ForceSet(retval, Nan::New("rva_db").ToLocalChecked(), Nan::New(rva_db)); + info.GetReturnValue().Set(retval); +} +NAN_METHOD(VolumeSet) { + Nan::HandleScope scope; + audio_output_t *ao = UnwrapPointer(info[0]); +#if 1 + double vol = info[1]->DoubleValue(); + int r = mpg123_volume(&ao->original, vol); +#else + double change = info[1]->DoubleValue(); + int r = mpg123_volume_change(&ao->original, change); +#endif + info.GetReturnValue().Set(r); +} +#endif + + NAN_METHOD(Flush) { Nan::HandleScope scope; audio_output_t *ao = UnwrapPointer(info[0]); @@ -244,7 +275,12 @@ void Initialize(Handle target) { Nan::SetMethod(target, "write", Write); Nan::SetMethod(target, "flush", Flush); Nan::SetMethod(target, "close", Close); - Nan::SetMethod(target, "progess", Progress); //added -dj 12/15/18 +//-dj 12/15/18 added: + Nan::SetMethod(target, "progess", Progress); +#ifdef WANT_VOLUME + Nan::SetMethod(target, "volume_get", VolumeGet); + Nan::SetMethod(target, "volume_set", VolumeSet); +#endif } } // anonymous namespace From 61af96db7a30e94b8f815b15da6ad4f272b7c47c Mon Sep 17 00:00:00 2001 From: djulien Date: Tue, 18 Dec 2018 22:27:23 -0800 Subject: [PATCH 6/6] add volume control (broken) --- src/binding.cc | 140 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 118 insertions(+), 22 deletions(-) diff --git a/src/binding.cc b/src/binding.cc index 34bd3c5..173298a 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -3,6 +3,7 @@ #include #include //now(), duration<> #include //std::map<> +#include //tan() #include "node_pointer.h" #include "output.h" @@ -15,6 +16,8 @@ extern mpg123_module_t mpg123_output_module_info; namespace { +#define WANT_VOLUME + //helper macros: //#define TOSTR_NESTED(str) #str //kludge: need nested macro for this to work //#define TOSTR(str) TOSTR_NESTED(str) @@ -24,6 +27,8 @@ namespace { //using Now = std::chrono::high_resolution_clock::now(); auto now() { return std::chrono::high_resolution_clock::now(); } //use high res clock for more accurate progress -dj 12/15/18 +constexpr double PI() { return std::atan(1) * 4; } //https://stackoverflow.com/questions/1727881/how-to-use-the-pi-constant-in-c + //add state info to audio_output struct: -dj 12/15/18 typedef struct audio_output_MODIFIED_struct { @@ -31,6 +36,7 @@ typedef struct audio_output_MODIFIED_struct long wrtotal; //total bytes written // long timestamp; //theoretical presentation time based on data samples decltype(now()) enque, start, finish, epoch; //= Now(); //system time of last write (enqueue, start, finish), current time + float volume, multiplier; //volume control audio_output_t original; //original untouched struct; put at end in case caller extends it //methods: double elapsed_usec(decltype(now())& when) @@ -39,6 +45,7 @@ typedef struct audio_output_MODIFIED_struct /*std::chrono::duration*/ auto diff = when - epoch; return std::chrono::duration_cast(diff).count(); } +// int sample_size; static int SampleSize(int fmt) { static const std::map size_map = //map sample type => size @@ -80,6 +87,8 @@ NAN_METHOD(Open) { ao->original.rate = info[2]->Int32Value(); /* sample rate */ ao->original.format = info[3]->Int32Value(); /* MPG123_ENC_* format */ ao->epoch = now(); //remember init time -dj 12/15/18 + ao->volume = 1.0; + ao->multiplier = 1.0; if (info[4]->IsString()) { v8::Local deviceString = info[4]->ToString(); @@ -114,6 +123,77 @@ NAN_METHOD(Write) { req->written = 0; req->callback = new Nan::Callback(info[3].As()); ao->enque = now(); //remember when write was queued (trying to measure latency) -dj 12/15/18 +//apply volume control here: +//CAUTION: alters data in-place; caller might not like it +//logic taken and generalized from https://github.com/reneraab/pcm-volume/ +//combined in here to reduce overhead (RPi doesn't have a lot of CPU power) +// info.GetReturnValue().Set(Nan::New(ao->volume)); +//#ifdef WANT_VOLUME +//printf("write: vol %f, mult %f\n", ao->volume, ao->multiplier); + switch (ao->original.format) + { +//logic taken from https://github.com/reneraab/pcm-volume/ + case MPG123_ENC_SIGNED_16: + if (ao->volume == 1.0) break; + for (int i = req->len / 2; i > 0; --i) + { + int32_t adjusted = ao->multiplier * *(int16_t*)buffer; //multiply Int16 by volume multiplier and round down +//clamp result to signed 16-bit value: + if (adjusted > 0x7FFF) adjusted = 0x7FFF; + if (adjusted < -0x7FFF) adjusted = -0x7FFF; + *(int16_t*)buffer++ = adjusted; + } + break; +//other cases generalized from above: + case MPG123_ENC_UNSIGNED_16: + if (ao->volume == 1.0) break; + for (int i = req->len / 2; i > 0; --i) + { + int32_t adjusted = ao->multiplier * *(uint16_t*)buffer; //multiply Int16 by volume multiplier and round down +//clamp result to unsigned 16-bit value: +//this seems incorrect for uint16; changed to 0..65535 +// if (adjusted > 32767) adjusted = 32767; +// if (adjusted < -32767) adjusted = -32767; + if (adjusted > 0xFFFF) adjusted = 0xFFFF; + if (adjusted < 0) adjusted = 0; + *(uint16_t*)buffer++ = adjusted; + } + break; + case MPG123_ENC_SIGNED_8: + if (ao->volume == 1.0) break; + for (int i = req->len; i > 0; --i) + { + int32_t adjusted = ao->multiplier * *(int8_t*)buffer; +//clamp result to signed 8-bit value: + if (adjusted > 0x7F) adjusted = 0x7F; + if (adjusted < -0x7F) adjusted = -0x7F; + *(int8_t*)buffer++ = adjusted; + } + break; + case MPG123_ENC_UNSIGNED_8: + if (ao->volume == 1.0) break; + for (int i = req->len; i > 0; --i) + { + int32_t adjusted = ao->multiplier * *(uint8_t*)buffer; +//clamp result to unsigned 8-bit value: + if (adjusted > 0xFF) adjusted = 0xFF; + if (adjusted < 0) adjusted = 0; + *(uint8_t*)buffer++ = adjusted; + } + break; +//TODO: other cases as needed + case MPG123_ENC_FLOAT_32: + case MPG123_ENC_FLOAT_64: + case MPG123_ENC_SIGNED_24: + case MPG123_ENC_UNSIGNED_24: + case MPG123_ENC_SIGNED_32: + case MPG123_ENC_UNSIGNED_32: + default: //just leave as-is + if (ao->volume == 1.0) break; +// info.GetReturnValue().SetUndefined(); //retval to indicate if vol control applied or not + break; + } +//#endif req->req.data = req; @@ -150,7 +230,7 @@ void write_after (uv_work_t *req) { //caller can call this at precise intervals rather than receiving inprecise emitting async events //NOTE: info is based on queued writes, but there will be latency anyway; works okay if consistent/predictable NAN_METHOD(Progress) { - Nan::HandleScope scope; + Nan::EscapableHandleScope scope; //Nan::HandleScope scope; //Escapable needed when returning a new object audio_output_t *ao = UnwrapPointer(info[0]); v8::Local retval = Nan::New(); // retval->Set(Nan::New("numwr").ToLocalChecked(), info[0]->ToString()); @@ -170,41 +250,56 @@ NAN_METHOD(Progress) { Nan::ForceSet(retval, Nan::New("finish_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(ao->finish))); auto latest = now(); Nan::ForceSet(retval, Nan::New("now_usec").ToLocalChecked(), Nan::New(ao->elapsed_usec(latest))); - info.GetReturnValue().Set(retval); + info.GetReturnValue().Set(scope.Escape(retval)); ao->epoch = latest; } -//TODO: broken: #define WANT_VOLUME -#ifdef WANT_VOLUME +//#ifdef WANT_VOLUME //added methods to get/set volume -dj 12/17/18 //not sure if stream needs to be paused to call this //api docs at https://www.mpg123.de/api/group__mpg123__voleq.shtml NAN_METHOD(VolumeGet) { - Nan::HandleScope scope; +// Nan::EscapableHandleScope scope; //Nan::HandleScope scope; //Escapable needed when returning a new object + Isolate * isolate = info.GetIsolate(); //https://github.com/freezer333/nodecpp-demo/tree/master/conversions audio_output_t *ao = UnwrapPointer(info[0]); - double base, really, rva_db; - int r = mpg123_get_volume(&ao->original, &base, &really, &rva_db); - if (r) { info.GetReturnValue().Set(scope.Escape(Nan::New(r))); return; } //error - v8::Local retval = Nan::New(); - Nan::ForceSet(retval, Nan::New("base").ToLocalChecked(), Nan::New(base)); - Nan::ForceSet(retval, Nan::New("actual").ToLocalChecked(), Nan::New(really)); - Nan::ForceSet(retval, Nan::New("rva_db").ToLocalChecked(), Nan::New(rva_db)); +// double base, really, rva_db; +// int r = mpg123_get_volume(&ao->original, &base, &multipler, &rva_db); +// if (r) { info.GetReturnValue().Set(scope.Escape(Nan::New(r))); return; } //error +// v8::Local retval = Nan::New(); +// Nan::ForceSet(retval, Nan::New("base").ToLocalChecked(), Nan::New(base)); +// Nan::ForceSet(retval, Nan::New("actual").ToLocalChecked(), Nan::New(really)); +// Nan::ForceSet(retval, Nan::New("rva_db").ToLocalChecked(), Nan::New(rva_db)); +// info.GetReturnValue().Set(retval); + v8::Local retval = v8::Integer::New(isolate, ao->volume); +// v8::Local retval = v8::Number::New(isolate, value); +// info.GetReturnValue().Set(scope.Escape(Nan::New(r))); info.GetReturnValue().Set(retval); +// info.GetReturnValue().Set(/*scope.Escape*/(Nan::Newvolume))); } + + NAN_METHOD(VolumeSet) { - Nan::HandleScope scope; +// Nan::EscapableHandleScope scope; //Nan::HandleScope scope; //Escapable needed when returning a new object audio_output_t *ao = UnwrapPointer(info[0]); -#if 1 - double vol = info[1]->DoubleValue(); - int r = mpg123_volume(&ao->original, vol); -#else - double change = info[1]->DoubleValue(); - int r = mpg123_volume_change(&ao->original, change); -#endif - info.GetReturnValue().Set(r); +//#if 1 +// double vol = info[1]->DoubleValue(); +// int r = mpg123_volume(&ao->original, vol); +//#else +// double change = info[1]->DoubleValue(); +// int r = mpg123_volume_change(&ao->original, change); +//#endif +// info.GetReturnValue().Set(r); + ao->volume = info[1]->NumberValue(); +//see notes about volume control from https://github.com/reneraab/pcm-volume +//see also c.f. https://dsp.stackexchange.com/questions/2990/how-to-change-volume-of-a-pcm-16-bit-signed-audio/2996#2996 +//this.multiplier = Math.pow(10, (-48 + 54*this.volume)/20); +//see also c.f. http://www.ypass.net/blog/2010/01/pcm-audio-part-3-basic-audio-effects-volume-control/ + ao->multiplier = tan(ao->volume * PI() / 4); //vol 1 => mult 1.0 +//printf("setvol: vol %f, mult %f\n", ao->volume, ao->multiplier); + info.GetReturnValue().Set(/*scope.Escape*/(Nan::New(ao->volume))); } -#endif +//#endif NAN_METHOD(Flush) { @@ -252,6 +347,7 @@ void Initialize(Handle target) { Nan::ForceSet(target, Nan::New("formats").ToLocalChecked(), Nan::New(ao.original.get_formats(&ao.original))); ao.original.close(&ao.original); ao.epoch = now(); //remember init time -dj 12/15/18 + ao.multiplier = 1.0; target->Set(Nan::New("sizeof_audio_output_t").ToLocalChecked(), Nan::New(static_cast(sizeof(audio_output_t))));