UNPKG

@gamestdio/audiosprite

Version:

Concat small audio files into single file and export in many formats.

422 lines (367 loc) 12.5 kB
import fs from 'fs'; import path from 'path'; import async from 'async'; import _ from 'underscore'; import glob from 'glob'; // @ts-ignore import ffmpegPath from 'ffmpeg-static'; export type AudioFormat = 'aiff' | 'wav' | 'ac3' | 'mp3' | 'mp4' | 'm4a' | 'ogg' | 'opus' | 'webm'; export type OutputFormat = 'default' | 'howler' | 'howler2' | 'jukebox' | 'createjs'; export interface AudioSpriteOutput { default: { resources: string[], spritemap: { [name: string]: { start: number, end: number, loop: boolean } }; autoplay?: boolean | string; }, howler: { urls: string; sprite: { [name: string]: [number, number, boolean]}; }, howler2: { src: string; sprite: { [name: string]: [number, number, boolean]}; }, createjs: { src: string; data: { audioSprite: Array<{id: string, startTime: number, duration: number}> }; } }; export type AudioSpriteOptions<F> = { output?: string, path?: string, export?: string | AudioFormat[], format?: F, autoplay?: boolean, loop?: string[], silence?: number, gap?: number, minlength?: number, bitrate?: 32 | 64 | 96 | 128 | 160 | 192 | 256 | 320, vbr?: number, 'vbr:vorbis'?: number, samplerate?: number, channels?: number, rawparts?: string | string[], ignorerounding?: number, logger?: { debug: Function, info: Function, log: Function } } const defaults: AudioSpriteOptions<'default'> = { output: 'output', path: '', export: 'ogg,m4a,mp3,ac3', format: 'default', autoplay: undefined, loop: [], silence: 0, gap: 1, minlength: 0, bitrate: 128, vbr: -1, 'vbr:vorbis': -1, samplerate: 44100, channels: 1, rawparts: '', ignorerounding: 0, logger: { debug: function(){}, info: function(){}, log: function(){} } } export default function <F extends keyof AudioSpriteOutput> (files: string[], options?: AudioSpriteOptions<F>): Promise<AudioSpriteOutput[F]> { return new Promise((resolve, reject) => { if (!files || !files.length) { return reject(new Error('No files provided')); } else { files = _.flatten(files.map(file => glob.sync(file))); } options = _.extend({}, defaults, options) // make sure output directory exists const outputDir = path.dirname(options.output) if (!fs.existsSync(outputDir)) { require('mkdirp').sync(outputDir) } let offsetCursor = 0 const wavArgs = ['-ar', `${options.samplerate}`, '-ac', `${options.channels}`, '-f', 's16le'] const tempFile = mktemp('audiosprite') options.logger.debug('Created temporary file', { file: tempFile }) const json: AudioSpriteOutput['default'] = { resources: [], spritemap: {} } spawn(ffmpegPath, ['-version']).on('exit', code => { if (code) { return reject(new Error('ffmpeg was not found on your path')); } if (options.silence) { json.spritemap.silence = { start: 0, end: options.silence, loop: true } if (!options.autoplay) { json.autoplay = 'silence' } appendSilence(options.silence + options.gap, tempFile, processFiles) } else { processFiles() } }) function mktemp(prefix) { var tmpdir = require('os').tmpdir() || '.'; return path.join(tmpdir, prefix + '.' + Math.random().toString().substr(2)); } function spawn(name, opt) { options.logger.debug('Spawn', { cmd: [name].concat(opt).join(' ') }); return require('child_process').spawn(name, opt); } function pad(num, size) { var str = num.toString(); while (str.length < size) { str = '0' + str; } return str; } function makeRawAudioFile(src, cb) { var dest = mktemp('audiosprite') options.logger.debug('Start processing', { file: src }) fs.exists(src, function(exists) { if (exists) { let code = -1 let signal = undefined; const ffmpeg = spawn(ffmpegPath, ['-i', path.resolve(src)].concat(wavArgs).concat('pipe:')) const streamFinished = _.after(2, function () { if (code) { return cb({ msg: 'File could not be added', file: src, retcode: code, signal: signal }) } cb(null, dest) }); const writeStream = fs.createWriteStream(dest, {flags: 'w'}); ffmpeg.stdout.pipe(writeStream); writeStream.on('close', () => streamFinished()); ffmpeg.on('close', function(_code, _signal) { code = _code signal = _signal streamFinished() }); } else { cb({ msg: 'File does not exist', file: src }) } }) } function appendFile(name, src, dest, cb) { var size = 0 var reader = fs.createReadStream(src) var writer = fs.createWriteStream(dest, { flags: 'a' }) reader.on('data', function(data) { size += data.length }) reader.on('close', function() { var originalDuration = size / options.samplerate / options.channels / 2 options.logger.info('File added OK', { file: src, duration: originalDuration }) var extraDuration = Math.max(0, options.minlength - originalDuration) var duration = originalDuration + extraDuration json.spritemap[name] = { start: offsetCursor , end: offsetCursor + duration , loop: name === options.autoplay || options.loop.indexOf(name) !== -1 } offsetCursor += originalDuration var delta = Math.ceil(duration) - duration; if (options.ignorerounding) { options.logger.info('Ignoring nearest second silence gap rounding'); extraDuration = 0; delta = 0; } appendSilence(extraDuration + delta + options.gap, dest, cb) }) reader.pipe(writer) } function appendSilence(duration, dest, cb) { var buffer = Buffer.alloc(Math.round(options.samplerate * 2 * options.channels * duration), 0); var writeStream = fs.createWriteStream(dest, { flags: 'a' }) writeStream.end(buffer) writeStream.on('close', function() { options.logger.info('Silence gap added', { duration: duration }) offsetCursor += duration cb() }) } function exportFile(src, dest, ext, opt, store, cb) { var outfile = dest + '.' + ext; spawn(ffmpegPath, ['-y', '-ar', options.samplerate, '-ac', options.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.resources.push(dest + '.caf') } fs.unlinkSync(outfile) cb() }) } else { options.logger.info('Exported ' + ext + ' OK', { file: outfile }) if (store) { json.resources.push(outfile) } cb() } }) } function exportFileCaf(src, dest, cb) { 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 }) } options.logger.info('Exported caf OK', { file: dest }) return cb() }) } function processFiles() { let formats: { [format in AudioFormat]: string[] } = { aiff: [], wav: [], ac3: ['-acodec', 'ac3', '-ab', options.bitrate + 'k'], mp3: ['-ar', `${options.samplerate}`, '-f', 'mp3'], mp4: ['-ab', options.bitrate + 'k'], m4a: ['-ab', options.bitrate + 'k', '-strict', '-2'], ogg: ['-acodec', 'libvorbis', '-f', 'ogg', '-ab', options.bitrate + 'k'], opus: ['-acodec', 'libopus', '-ab', options.bitrate + 'k'], webm: ['-acodec', 'libvorbis', '-f', 'webm', '-dash', '1'], }; if (options.vbr >= 0 && options.vbr <= 9) { formats.mp3 = formats.mp3.concat(['-aq', `${options.vbr}`]) } else { formats.mp3 = formats.mp3.concat(['-ab', options.bitrate + 'k']) } // change quality of webm output - https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide if (options['vbr:vorbis'] >= 0 && options['vbr:vorbis'] <= 10) { formats.webm = formats.webm.concat(['-qscale:a', String(options['vbr:vorbis'])]) } else { formats.webm = formats.webm.concat(['-ab', options.bitrate + 'k']) } if (typeof (options.export) === "string") { formats = (options.export.split(',') as AudioFormat[]).reduce(function(memo, val) { if (formats[val]) { memo[val] = formats[val]; } return memo; }, {} as any); } var rawparts = typeof (options.rawparts) === "string" ? options.rawparts.split(',') : null var i = 0 options.logger.info(files); async.forEachSeries(files, function(file, cb) { i++ makeRawAudioFile(file, function(err, tmp) { if (err) { options.logger.debug(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) { options.logger.debug('Start export slice', { name: name, format: ext, i: i }) exportFile(tmp, options.output + '_' + pad(i, 3), ext, formats[ext] , false, cb) }, tempProcessed) } else { tempProcessed() } }) }) }, function(err) { if (err) { return reject(new Error(`Error adding file ${err.message}`)); } async.forEachSeries(Object.keys(formats), function(ext, cb) { options.logger.debug('Start export', { format: ext }) exportFile(tempFile, options.output, ext, formats[ext], true, cb) }, function(err) { if (err) { return reject(new Error('Error exporting file')); } if (options.autoplay) { json.autoplay = options.autoplay } json.resources = json.resources.map(function(e) { return options.path ? path.join(options.path, path.basename(e)) : e }) var finalJson: any = {} switch (options.format) { case 'howler': case 'howler2': finalJson[options.format === 'howler' ? 'urls' : 'src'] = [].concat(json.resources) finalJson.sprite = {} for (var sn in json.spritemap) { var spriteInfo = json.spritemap[sn] finalJson.sprite[sn] = [spriteInfo.start * 1000, (spriteInfo.end - spriteInfo.start) * 1000] if (spriteInfo.loop) { finalJson.sprite[sn].push(true) } } break; case 'createjs': finalJson.src = json.resources[0] finalJson.data = {audioSprite: []} for (var sn in json.spritemap) { var spriteInfo = json.spritemap[sn] finalJson.data.audioSprite.push({ id: sn, startTime: spriteInfo.start * 1000, duration: (spriteInfo.end - spriteInfo.start) * 1000 }) } break case 'default': default: finalJson = json break } fs.unlinkSync(tempFile) return resolve(finalJson); }) }); } }); }