wx-voice
Version:
Convert audio files between Tencent apps (Weixin / Wechat, QQ) and Silk codec with other general format such as MP3 and M4A
473 lines (358 loc) • 13.4 kB
JavaScript
/*
[wx-voice]
Convert audio files between Tencent apps (Weixin / Wechat, QQ) and Silk codec with other general format such as MP3 and M4A
Github: https://github.com/Ang-YC/wx-voice
Author: AngYC <me@angyc.com>
*/
;
const EventEmitter = require('events');
const { spawn } = require('child_process');
const Ffmpeg = require('fluent-ffmpeg');
const os = require('os');
const fs = require('fs');
const path = require('path');
const which = require('which');
const dataUri = require('strong-data-uri');
const readChunk = require('read-chunk');
const randomatic = require('randomatic');
class WxVoice extends EventEmitter {
constructor(tempDir = os.tmpdir(), ffmpegPath) {
super();
this._tempDir = path.resolve(tempDir);
this._ffmpegPath = ffmpegPath;
// Check if dependencies are available
this._checkDependencies();
}
decode(input, output, options, callback) {
var ext, buffer,
fileFormat;
// Make it into absolute path
input = path.resolve(input);
output = path.resolve(output);
// Set options default to {}
if (options === undefined) {
options = {};
}
// Set format as extension if undefined
if (options.format === undefined) {
ext = path.extname(output);
if (ext[0] == ".")
ext = ext.substr(1);
options.format = ext;
}
// Callback after decode is done
callback = validateFunction(callback);
// Check if file exists and get file format
try {
buffer = readChunk.sync(input, 0, 4100);
fileFormat = fileType(buffer);
} catch (e) {
this.emit("error", e);
return callback();
}
// Check if file is silk, webm or others
if (fileFormat && fileFormat.mime == "audio/silk") {
// Default frequency
var outputPCM = (options.format == "pcm"),
silkFrequency = (outputPCM && options.frequency) ? options.frequency : 24000;
// Use Silk if it can be decoded
this._decodeSilk(input, silkFrequency, (tempFile) => {
input = tempFile || input;
// Output raw PCM directly
if (outputPCM && tempFile) {
copy(tempFile, output, (err) => {
this._deleteTempFile(tempFile);
callback(err ? undefined : output);
});
// Else Continue for other formats
} else {
this._convert(tempFile != undefined, false, input, output, options, (res) => {
this._deleteTempFile(tempFile);
callback(res);
});
}
});
} else {
// Use WebM output if it is WebM
this._tryWebM(input, (tempFile) => {
input = tempFile || input;
this._convert(false, false, input, output, options, (res) => {
this._deleteTempFile(tempFile);
callback(res);
});
});
}
}
encode(input, output, options, callback) {
var ext, tempFile;
// Make it into absolute path
input = path.resolve(input);
output = path.resolve(output);
// Set options default to {}
if (options === undefined) {
options = {};
}
// Set format as extension if undefined
if (options.format === undefined) {
ext = path.extname(output);
if (ext[0] == ".")
ext = ext.substr(1);
options.format = ext;
}
// Callback after encode is done
callback = validateFunction(callback);
// Check if file exists
if (!fs.existsSync(input)) {
this.emit("error", "Error: ENOENT: no such file or directory, open '" + input + "'");
return callback();
}
if (options.format == "silk" || options.format == "silk_amr") {
tempFile = this._getTempFile(input + ".pcm");
this._convert(false, true, input, tempFile, options, (tempOutput) => {
if (tempOutput) {
this._encodeSilk(tempOutput, output, options.format, (res) => {
this._deleteTempFile(tempOutput);
callback(res);
});
} else {
callback();
}
});
} else if (options.format == "webm") {
tempFile = this._getTempFile(input + ".temp.webm");
this._convert(false, true, input, tempFile, options, (tempOutput) => {
if (tempOutput) {
this._encodeWebM(tempOutput, output, (res) => {
this._deleteTempFile(tempOutput);
callback(res);
});
} else {
callback();
}
});
} else {
this.emit("error", new Error(options.format + " is not a valid encode format, only silk, silk_amr and webm allowed"));
return callback();
}
}
duration(filePath, callback) {
Ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) return callback(0);
if (metadata && metadata.format) {
var duration = parseFloat(metadata.format.duration);
duration = (isNaN(duration)) ? 0 : duration;
}
callback(duration);
});
}
_convert(rawInput, rawOutput, input, output, options, callback) {
var started = false,
format = options.format,
bitrate = options.bitrate,
frequency = options.frequency,
channels = options.channels,
ffmpeg = Ffmpeg(input)
.on("start", onStart)
.on("error", onError)
.on("end", onEnd);
// Additional parameters for raw
if (rawInput) {
ffmpeg = ffmpeg.inputFormat("s16le").inputOptions(["-ar 24000", "-ac 1"]);
} else if (rawOutput) {
if (format == "silk" || format == "silk_amr") {
format = "s16le";
ffmpeg = ffmpeg.outputOptions(["-ar 24000", "-ac 1"]);
} else if (format == "webm") {
ffmpeg = ffmpeg.outputOptions(["-ar 48000", "-ac 1"]).audioCodec("opus");
}
}
// Other settings
if (bitrate) { ffmpeg = ffmpeg.audioBitrate(bitrate); }
if (frequency) { ffmpeg = ffmpeg.audioFrequency(frequency); }
if (channels) { ffmpeg = ffmpeg.audioChannels(channels); }
// Format dependent
if (format == "m4a") {
ffmpeg = ffmpeg.audioCodec("aac");
} else if (format == "pcm") {
ffmpeg = ffmpeg.format("s16le");
} else {
ffmpeg = ffmpeg.format(format);
}
// Output
ffmpeg.noVideo().save(output);
function onStart(commandLine) {
started = true;
}
function onError(err, stdout, stderr) {
started = false;
callback();
}
function onEnd(stdout, stderr) {
if (started) {
started = false;
callback(output);
}
}
}
_decodeSilk(input, frequency, callback) {
var output = this._getTempFile(input + ".pcm"),
decoder = spawn(this._getSilkSDK("decoder"), [input, output, "-Fs_API", frequency]);
// Allow it to output
decoder.stdout.on('data', (data) => { });
decoder.stderr.on('data', (data) => { });
decoder.on('close', (code) => {
if (code == 1) { // Error occured
callback();
} else { // Success
callback(output);
}
});
}
_encodeSilk(input, output, type, callback) {
var flag = (type == "silk_amr" ? "-tencent_amr" : "-tencent"),
encoder = spawn(this._getSilkSDK("encoder"), [input, output, flag]);
// Allow it to output
encoder.stdout.on('data', (data) => { });
encoder.stderr.on('data', (data) => { });
encoder.on('close', (code) => {
if (code == 1) { // Error occured
callback();
} else { // Success
callback(output);
}
});
}
_tryWebM(input, callback) {
var output = this._getTempFile(input + ".webm"),
base64 = "";
fs.readFile(input, (err, data) => {
if (err) return callback();
// Convert to string and check if Data URI is WebM
base64 = data.toString();
if (base64.startsWith("data:audio/webm;base64,")) {
this._parseWebM(base64, output, callback);
} else {
callback();
}
});
}
_parseWebM(base64, output, callback) {
var buffer;
// Convert to buffer
try {
buffer = dataUri.decode(base64);
} catch (e) {
return callback();
}
// Write to file
fs.writeFile(output, buffer, (err) => {
if (err) return callback();
callback(output);
});
}
_encodeWebM(input, output, callback) {
var uri = "";
fs.readFile(input, (err, data) => {
if (err) return callback();
uri = dataUri.encode(data, "audio/webm");
// Write to file
fs.writeFile(output, uri, (err) => {
if (err) return callback();
callback(output);
});
});
}
_checkDependencies() {
var silkDecoder = this._getSilkSDK("decoder"),
silkEncoder = this._getSilkSDK("encoder"),
ffmpegPath = this._ffmpegPath;
// Check if Silk SDK is available
if (!fs.existsSync(silkDecoder) || !fs.existsSync(silkEncoder)) {
throw new Error("Silk SDK not found, make sure you compiled using command: wx-voice compile");
}
if (ffmpegPath && fs.existsSync(ffmpegPath)) {
this._ffmpegPath = path.resolve(ffmpegPath);
Ffmpeg.setFfmpegPath(this._ffmpegPath);
} else if (ffmpegPath = this._getFfmpegPath()) {
this._ffmpegPath = path.resolve(ffmpegPath);
} else {
throw new Error("FFMPEG not found");
}
}
_getSilkSDK(type) {
return path.resolve(__dirname, "silk", type);
}
_getFfmpegPath() {
// Get FFMPEG Path (Sync version of _getFfmpegPath in fluent-ffmpeg)
var path;
// Search in FFMPEG_PATH
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
path = process.env.FFMPEG_PATH;
// Search in PATH, return null if not found
} else {
path = which.sync("ffmpeg", { nothrow: true });
}
// Return undefined
if (path === null || path === "") path = undefined;
return path;
}
_getTempFile(fileName, noPrefix) {
var file = path.basename(fileName);
if (!noPrefix)
file = randomatic("a0", 16) + "_" + file;
return path.resolve(this._tempDir, file);
}
_deleteTempFile(fileName) {
if (fileName) {
fileName = this._getTempFile(fileName, true);
fs.unlink(fileName, () => {});
}
}
}
// Utililities
function fileType(input) {
const buf = new Uint8Array(input);
if (!(buf && buf.length > 1)) {
return null;
}
const check = (header) => {
for (let i = 0; i < header.length; i++) {
if (header[i] !== buf[i]) {
return false;
}
}
return true;
};
if (check([0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B, 0x0A]) || // Skype V1: #!SILK\n (https://tools.ietf.org/html/draft-spittka-silk-payload-format-00)
check([0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B, 0x5F, 0x56, 0x33]) || // Skype V3: #!SILK_V3
check([0x02, 0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B, 0x5F, 0x56, 0x33]) || // Tencent variation: .#!SILK_V3
check([0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A, 0x02, 0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B, 0x5F, 0x56, 0x33])) { // Tencent AMR variation: #!AMR\n.#!SILK_V3
return {
ext: 'sil',
mime: 'audio/silk'
};
}
return null;
}
function isFunction(f) {
return (f && typeof f === 'function');
}
function validateFunction(f) {
return (isFunction(f) ? f : function() { });
}
function copy(source, target, callback) {
var completed = false;
var rd = fs.createReadStream(source);
var wr = fs.createWriteStream(target);
rd.on("error", done);
wr.on("error", done);
wr.on("close", (ex) => { done(); });
rd.pipe(wr);
function done(err) {
if (!completed) {
completed = true;
callback(err);
}
}
}
module.exports = WxVoice;