audiospritler
Version:
Concat small audio files into single file and export in many formats for use with Howler.js.
290 lines (260 loc) • 9.12 kB
JavaScript
#!/usr/bin/env node
var fs = require('fs'),
path = require('path'),
async = require('async'),
_ = require('underscore')._,
winston = require('winston');
var optimist = require('optimist')
.options('output', {
alias: 'o', 'default': 'output', describe: 'Name for the output file.'
})
.options('export', {
alias: 'e', 'default': '', describe: 'Limit exported file types. Comma separated extension list.'
})
.options('log', {
alias: 'l', 'default': 'info', describe: 'Log level (debug, info, notice, warning, error).'
})
.options('autoplay', {
alias: 'a', 'default': null, describe: 'Autoplay sprite name'
})
.options('silence', {
alias: 's', 'default': 0, describe: 'Add special "silence" track with specified duration.'
})
.options('samplerate', {
alias: 'r', 'default': 44100, describe: 'Sample rate.'
})
.options('channels', {
alias: 'c', 'default': 1, describe: 'Number of channels (1=mono, 2=stereo).'
})
.options('rawparts', {
alias: 'p', 'default': '', describe: 'Include raw slices(for Web Audio API) in specified formats.'
})
.options('help', {
alias: 'h', describe: 'Show this help message.'
});
var argv = optimist.argv;
winston.remove(winston.transports.Console);
winston.add(winston.transports.Console, {
colorize: true, level: argv.log, handleExceptions: false
});
winston.debug('Parsed arguments', argv);
var SAMPLE_RATE = parseInt(argv.samplerate, 10),
NUM_CHANNELS = parseInt(argv.channels, 10),
files = _.uniq(argv._);
if (argv.help || !files.length) {
if (!argv.help) {
winston.error('No input files specified.');
}
winston.info('Usage: audiospritler [options] file1.mp3 file2.mp3 *.wav');
winston.info(optimist.help());
process.exit(1);
}
var offsetCursor = 0,
wavArgs = ['-ar', SAMPLE_RATE, '-ac', NUM_CHANNELS, '-f', 's16le'],
tempFile = mktemp('audiospritler'),
json = { urls: [], sprite: {} };
winston.debug('Created temporary file', { file: tempFile });
spawn('ffmpeg', ['-version'])
.on('error', function() {
"use strict";
winston.error('ffmpeg was not found on your path');
process.exit(1);
})
.on('exit', function () {
"use strict";
if (argv.silence) {
json.sprite.silence = [0, argv.silence * 1000, true];
if (!argv.autoplay) {
json.autoplay = true;
}
appendSilence(argv.silence + 1, tempFile, processFiles);
} else {
processFiles();
}
});
function mktemp(prefix) {
"use strict";
var tmpdir = require('os').tmpDir() || '.';
return path.join(tmpdir, prefix + '.' + Math.random().toString().substr(2));
}
function spawn(name, opt) {
"use strict";
winston.debug('Spawn', { cmd: [name].concat(opt).join(' ') });
return require('child_process').spawn(name, opt);
}
function pad(num, size) {
"use strict";
var str = num.toString();
while (str.length < size) {
str = '0' + str;
}
return str;
}
function makeRawAudioFile(src, cb) {
"use strict";
var dest = mktemp('audiospritler');
winston.debug('Start processing', { file: src});
fs.exists(src, function (exists) {
if (exists) {
var ffmpeg = spawn('ffmpeg', ['-i', path.resolve(src)]
.concat(wavArgs).concat('pipe:'));
ffmpeg.stdout.pipe(fs.createWriteStream(dest, {flags: 'w'}));
ffmpeg.on('exit', function (code, signal) {
if (code) {
return cb({
msg: 'File could not be added',
file: src,
retcode: code,
signal: signal
});
}
cb(null, dest);
});
}
else {
cb({ msg: 'File does not exist', file: src });
}
});
}
function appendFile(name, src, dest, cb) {
"use strict";
var size = 0,
reader = fs.createReadStream(src),
writer = fs.createWriteStream(dest, {
flags: 'a'
});
reader.on('data', function (data) {
size += data.length;
});
reader.on('end', function () {
var duration = size / SAMPLE_RATE / NUM_CHANNELS / 2;
winston.info('File added OK', { file: src, duration: duration });
json.sprite[name] = [offsetCursor * 1000, Math.round((duration) * 1000)];
offsetCursor += duration;
appendSilence(Math.ceil(duration) - duration + 1, dest, cb);
});
reader.pipe(writer);
}
function appendSilence(duration, dest, cb) {
"use strict";
var buffer = new Buffer(Math.round(SAMPLE_RATE * 2 * NUM_CHANNELS * duration)),
writeStream = fs.createWriteStream(dest, { flags: 'a' });
buffer.fill(null);
writeStream.on('close', function () {
winston.info('Silence gap added', { duration: duration });
offsetCursor += duration;
cb();
});
writeStream.end(buffer);
}
function exportFile(src, dest, ext, opt, store, cb) {
"use strict";
var outfile = dest + '.' + ext;
spawn('ffmpeg', ['-y', '-ac', NUM_CHANNELS, '-f', 's16le', '-i', src]
.concat(opt).concat(outfile))
.on('exit', function (code, signal) {
if (code) {
return cb({
msg: 'Error exporting file',
format: ext,
retcode: code,
signal: signal
});
}
if (ext === 'aiff') {
exportFileCaf(outfile, dest + '.caf', function (err) {
if (!err && store) {
json.urls.push(dest + '.caf');
}
fs.unlinkSync(outfile);
cb();
});
} else {
winston.info("Exported " + ext + " OK", { file: outfile })
if (store) {
json.urls.push(outfile);
}
cb();
}
});
}
function exportFileCaf(src, dest, cb) {
"use strict";
if (process.platform !== 'darwin') {
return cb(true);
}
spawn('afconvert', ['-f', 'caff', '-d', 'ima4', src, dest])
.on('exit', function (code, signal) {
if (code) {
return cb({
msg: 'Error exporting file',
format: 'caf',
retcode: code,
signal: signal
});
}
winston.info('Exported caf OK', { file: dest });
return cb();
});
}
function processFiles() {
"use strict";
var formats = {
aiff: [], ac3: '-acodec ac3'.split(' '), mp3: ['-ar', SAMPLE_RATE, '-ab', '128k', '-f', 'mp3'], m4a: [], ogg: '-acodec libvorbis -f ogg'.split(' ')
};
if (argv.export.length) {
formats = argv.export.split(',').reduce(function (memo, val) {
if (formats[val]) {
memo[val] = formats[val];
}
return memo;
}, {});
}
var rawparts = argv.rawparts.length ? argv.rawparts.split(',') : null;
var i = 0;
async.forEachSeries(files, function (file, cb) {
i++;
makeRawAudioFile(file, function (err, tmp) {
if (err) {
return cb(err);
}
function tempProcessed() {
fs.unlinkSync(tmp);
cb();
}
var name = path.basename(file).replace(/\.[a-zA-Z0-9]+$/, '');
appendFile(name, tmp, tempFile, function (err) {
if (rawparts !== null ? rawparts.length : void 0) {
async.forEachSeries(rawparts, function (ext, cb) {
winston.debug('Start export slice', { name: name, format: ext, i: i });
exportFile(tmp, argv.output + '_' + pad(i, 3), ext, formats[ext], false, cb);
}, tempProcessed);
} else {
tempProcessed();
}
});
});
}, function (err) {
if (err) {
winston.error('Error adding file', err);
process.exit(1);
}
async.forEachSeries(Object.keys(formats), function (ext, cb) {
winston.debug('Start export', { format: ext });
exportFile(tempFile, argv.output, ext, formats[ext], true, cb);
}, function (err) {
if (err) {
winston.error('Error exporting file', err);
process.exit(1);
}
if (argv.autoplay) {
json.autoplay = argv.autoplay;
}
var jsonfile = argv.output + '.json';
fs.writeFileSync(jsonfile, JSON.stringify(json, null, 2));
winston.info('Exported json OK', { file: jsonfile });
fs.unlinkSync(tempFile);
winston.info('All done');
});
});
}