'use strict'; function Text2Speech(adapter, libs, options, sayIt) { const that = this; const engines = require(__dirname + '/../admin/engines.js'); const sayitEngines = engines.sayitEngines; const MP3FILE = __dirname + '/../' + adapter.namespace + '.say.mp3'; let appkey = null; let cloudUrl = null; let polly = null; function splitText(text, max) { if (!max) max = 70; if (text.length > max) { const parts = text.split(/,|.|;|:/); const result = []; for (let p = 0; p < parts.length; p++) { if (parts[p].length < max) { result.push(parts[p]); continue; } const _parts = parts[p].split(' '); let i = 0; for (let w = 0; w < _parts.length; w++) { if (_parts[i] && ((result[i] || '') + ' ' + _parts[w]).length > max) { i++; } if (!result[i]) { result.push(_parts[w]); } else { result[i] += ' ' + _parts[w]; } } } return result; } else { return [text]; } } function copyFile(text, language, volume, source, dest, callback) { libs.fs = libs.fs || require('fs'); try { const input = libs.fs.createReadStream(source); // Input stream const output = libs.fs.createWriteStream(dest); // Output stream input.on('data', d =>output.write(d)); // Copy in to out input.on('error', err => { throw err; }); // Raise errors input.on('end', () => { // When input ends output.end(); // close output adapter.log.info('Copied file "' + source + '" to "' + dest + '"'); if (callback) callback(null, text, language, volume); // And notify callback }); } catch (e) { if (callback) callback('Cannot copy file "' + source + '" to "' + dest + '": ' + e, '', '', volume); // And notify callback } } function cacheFile(error, text, language, volume, seconds, callback) { libs.fs = libs.fs || require('fs'); libs.path = libs.path || require('path'); if (!error) { if (adapter.config.cache) { const md5filename = libs.path.join(options.cacheDir, libs.crypto.createHash('md5').update(language + ';' + text).digest('hex') + '.mp3'); const stat = libs.fs.statSync(MP3FILE); if (stat.size < 100) { adapter.log.warn('Received file is too short: ' + libs.fs.readFileSync(MP3FILE).toString()); } else { copyFile(text, language, volume, MP3FILE, md5filename, (error, text, language, volume) => { if (error) adapter.log.error(error); }); } } } callback(error, text, language, volume, seconds); } /* tex 必填 合成的文本,使用UTF-8编码。小于2048个中文字或者英文数字。(文本在百度服务器内转换为GBK后,长度必须小于4096字节) tok 必填 开放平台获取到的开发者access_token(见上面的“鉴权认证机制”段落) cuid 必填 用户唯一标识,用来计算UV值。建议填写能区分用户的机器 MAC 地址或 IMEI 码,长度为60字符以内 ctp 必填 客户端类型选择,web端填写固定值1 lan 必填 固定值zh。语言选择,目前只有中英文混合模式,填写固定值zh spd 选填 语速,取值0-15,默认为5中语速 pit 选填 音调,取值0-15,默认为5中语调 vol 选填 音量,取值0-15,默认为5中音量 per 选填 发音人选择, 0为普通女声,1为普通男生,3为情感合成-度逍遥,4为情感合成-度丫丫,默认为普通女声 aue 选填 3为mp3格式(默认); 4为pcm-16k;5为pcm-8k;6为wav(内容同pcm-16k); 注意aue=4或者6是语音识别要求的格式,但是音频内容不是语音识别要求的自然人发音,所以识别效果会受影响。 https://openapi.baidu.com/oauth/2.0/token?grant_type=client_credentials&client_id=QWM8zjGEEM2LfbGfQ2kn6pRA&client_secret=WhzukkSKnFagOZy3t4pD14bMvmmWiLOn */ function sayItGetSpeechBaidu(text, language, volume, callback) { var client_id = 'QWM8zjGEEM2LfbGfQ2kn6pRA'; var client_secret = 'WhzukkSKnFagOZy3t4pD14bMvmmWiLOn'; var url ='https://openapi.baidu.com/oauth/2.0/token?grant_type=client_credentials&client_id=' + client_id +'&client_secret=' +client_secret; var cuid ='1234ABCD_9876'; var tok='24.a15e4c6d4d0a37a931db986c3fe17660.2592000.1544513699.282335-10607289'; const options = { host: 'tsn.baidu.com', //port: 443, path: '/text2audio?lan=zh&ctp=1&cuid=' + cuid +'&tok=' + tok + '&tex=' + encodeURI(text) + '&vol=9&per=0&spd=5&pit=5&aue=3' }; if (!libs.https) libs.https = require('https'); if (!libs.fs) libs.fs = require('fs'); let sounddata = ''; libs.https.get(options, res => { res.setEncoding('binary'); res.on('data', chunk => sounddata += chunk); res.on('end', () => { if (sounddata.length < 100) { if (callback) callback('Cannot get file: received file is too short', text, language, volume, 0); return; } if (sounddata.toString().indexOf('302 Moved') !== -1) { if (callback) callback('http://' + options.host + options.path + '\nCannot get file: ' + sounddata, text, language, volume, 0); return; } libs.fs.writeFile(MP3FILE, sounddata, 'binary', err => { if (err) { if (callback) callback('File error: ' + err, text, language, volume, 0); } else { that.getLength(MP3FILE, (error, seconds) => { if (callback) callback(error, text, language, volume, seconds); }); } }); }); }).on('error', err => { sounddata = ''; if (callback) callback('Cannot get file:' + err, text, language, volume, 0); }); } function sayItGetSpeechGoogle(text, language, volume, callback) { if (typeof volume === 'function') { callback = volume; volume = undefined; } if (text.length === 0) { if (callback) callback(null, '', language, volume, 0); return; } if (text.length > 70) { const parts = splitText(text); for (let t = 1; t < parts.length; t++) { sayIt(parts[t], language, volume); } text = parts[0]; } language = language || adapter.config.engine; // https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&q=%D0%BE%D1%82%D0%B2%D0%B5%D1%82%D0%B8%D1%82%D1%8C%207&tl=ru&total=1&idx=0&textlen=10 const options = { host: 'translate.google.com', //port: 443, path: '/translate_tts?ie=UTF-8&client=tw-ob&q=' + encodeURI(text) + '&tl=' + language + '&total=1&idx=0&textlen=' + text.length // }; if (language === 'ru') { options.headers = { 'Accept-Encoding': 'identity;q=1, *;q=0', Range: 'bytes=0-', Referer: 'https://www.google.de/', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36' //"User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:25.0) Gecko/20100101 Firefox/25.0", //"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", //"Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3", //"Accept-Encoding": "gzip, deflate"// }; } if (!libs.https) libs.https = require('https'); if (!libs.fs) libs.fs = require('fs'); let sounddata = ''; libs.https.get(options, res => { res.setEncoding('binary'); res.on('data', chunk => sounddata += chunk); res.on('end', () => { if (sounddata.length < 100) { if (callback) callback('Cannot get file: received file is too short', text, language, volume, 0); return; } if (sounddata.toString().indexOf('302 Moved') !== -1) { if (callback) callback('http://' + options.host + options.path + '\nCannot get file: ' + sounddata, text, language, volume, 0); return; } libs.fs.writeFile(MP3FILE, sounddata, 'binary', err => { if (err) { if (callback) callback('File error: ' + err, text, language, volume, 0); } else { that.getLength(MP3FILE, (error, seconds) => { if (callback) callback(error, text, language, volume, seconds); }); } }); }); }).on('error', err => { sounddata = ''; if (callback) callback('Cannot get file:' + err, text, language, volume, 0); }); } function sayItGetSpeechAcapela(text, language, volume, callback) { const options = { host: 'vaassl3.acapela-group.com', path: '/Services/Synthesizer?prot_vers=2&req_voice=' + language + '22k&cl_env=FLASH_AS_3.0&req_text=%5Cvct%3D100%5C+%5Cspd%3D180%5C+' + encodeURI(text) + '&req_asw_type=STREAM&cl_vers=1-30&req_err_as_id3=yes&cl_login=ACAPELA_BOX&cl_app=PROD&cl_pwd=0g7znor2aa' }; if (!libs.https) libs.https = require('https'); let sounddata = ''; libs.https.get(options, res => { res.setEncoding('binary'); res.on('data', chunk => sounddata += chunk); res.on('end', () => { if (sounddata.length < 100) { if (callback) callback('Cannot get file: received file is too short', text, language, volume); return; } libs.fs.writeFile(MP3FILE, sounddata, 'binary', err => { if (err) { if (callback) callback('File error:' + err, text, language, volume); } else { console.log('File saved.'); if (callback) callback(null, text, language, volume); } }); }); }).on('error', err => { sounddata = ''; if (callback) callback('Cannot get file:' + err, text, language, volume); }); } function sayItGetSpeechYandex(text, language, volume, callback) { if (language === 'ru' || language === 'ru_YA') { language = 'ru-RU'; } if (!libs.https) libs.https = require('https'); /*emotion: good, neutral, evil, mixed drunk: true, false ill: true, false robot: true, false */ const options = { host: 'tts.voicetech.yandex.net', path: '/generate?lang=' + language + '&format=mp3&speaker=' + adapter.config.voice + '&key=' + adapter.config.key + '&text=' + encodeURI(text.trim()) }; if (adapter.config.emotion && adapter.config.emotion !== 'none') options.path += '&emotion=' + adapter.config.emotion; if (adapter.config.drunk === 'true' || adapter.config.drunk === true) options.path += '&drunk=true'; if (adapter.config.ill === 'true' || adapter.config.ill === true) options.path += '&ill=true'; if (adapter.config.robot === 'true' || adapter.config.robot === true) options.path += '&robot=true'; let sounddata = ''; libs.https.get(options, res => { res.setEncoding('binary'); res.on('data', chunk => sounddata += chunk); res.on('end', () => { if (sounddata.length < 100) { if (callback) callback('Cannot get file: received file is too short', text, language, volume, 0); return; } libs.fs.writeFile(MP3FILE, sounddata, 'binary', err => { if (err) { if (callback) callback('File error: ' + err, text, language, volume, 0); } else { if (callback) callback(null, text, language, volume); } }); }); }).on('error', err => { sounddata = ''; if (callback) callback('Cannot get file: ' + err, text, language, volume); }); } function sayItGetSpeechPolly(text, language, volume, callback) { if (!libs.aws) libs.aws = require('aws-sdk'); if (!libs.fs) libs.fs = require('fs'); try { polly = polly || new libs.aws.Polly({ accessKeyId: adapter.config.accessKey, secretAccessKey: adapter.config.secretKey, region: adapter.config.region }); let type = 'text'; if (text.match(/<[-+\w\s'"=]+>/)) { if (!text.match(/^/)) text = '' + text + ''; type = 'ssml'; } const params = { Text: text, OutputFormat: 'mp3', TextType: type, VoiceId: sayitEngines[language].ename }; polly.synthesizeSpeech(params, (err, data) => { if (err) { if (callback) callback('Cannot get answer: ' + JSON.stringify(err), text, language, volume, 0); } else if (data) { if (data.AudioStream instanceof Buffer) { libs.fs.writeFile(MP3FILE, data.AudioStream, 'binary', err => { if (err) { if (callback) callback('File error: ' + err, text, language, volume, 0); } else { that.getLength(MP3FILE, (error, seconds) => { if (callback) callback(error, text, language, volume, seconds); }); } }); } else { if (callback) callback('Answer in invalid format: ' + (data ? data.toString() : 'null'), text, language, volume, 0); } } }); } catch (e) { if (callback) callback(e.toString(), text, language, volume); } } function sayItGetSpeechCloud(text, language, volume, callback) { if (!libs.https) libs.https = require('https'); if (!libs.fs) libs.fs = require('fs'); if (!appkey) { adapter.getForeignObject('system.adapter.' + adapter.config.cloud, (err, obj) => { appkey = obj && obj.native && obj.native.apikey ? obj.native.apikey : 'error'; cloudUrl = (obj && obj.native && obj.native.cloudUrl) || ''; if (appkey && appkey.match(/^@pro_/)) { if (cloudUrl.indexOf('https://yunkong2.pro:') === -1 && cloudUrl.indexOf('https://yunkong2.info:') === -1) { cloudUrl = 'https://yunkong2.pro:10555'; } } cloudUrl = cloudUrl.replace(/https\:\/\//, '').replace(/:\d+/, ''); sayItGetSpeechCloud(text, language, volume, callback); }); return; } if (appkey === 'error') { if (callback) callback('No app key found in "' + adapter.config.cloud + '".', text, language, volume, 0); return; } try { let type = 'text'; if (text.match(/<[-+\w\s'"=]+>/)) { if (!text.match(/^/)) text = '' + text + ''; type = 'ssml'; } const postData = JSON.stringify({ Text: text, appkey: appkey, TextType: type, VoiceId: sayitEngines[language].ename }); const postOptions = { host: cloudUrl, port: 443, path: '/polly/', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; // Set up the request const postReq = libs.https.request(postOptions, (res) => { adapter.log.debug('Status code: ' + res.statusCode); if (res.statusCode === 200) { res.setEncoding('binary'); } else { res.setEncoding('utf8'); } let data = []; res.on('data', chunk => { if (typeof chunk === 'string') chunk = new Buffer(chunk, 'binary'); data.push(chunk); }); res.on('error', error => { if (callback) { callback('Cannot read: ' + error, text, language, volume, 0); callback = null; } }); res.on('end', () => { data = Buffer.concat(data); if (data instanceof Buffer && data.length > 500) { libs.fs.writeFile(MP3FILE, data, 'binary', err => { if (err) { if (callback) callback('File error: ' + err, text, language, volume, 0); } else { that.getLength(MP3FILE, (error, seconds) => { if (callback) callback(error, text, language, volume, seconds); }); } }); } else { if (callback) callback('Answer in invalid format: ' + (data ? data.toString().substring(0, 100) : 'null'), text, language, volume, 0); } }); }); // post the data postReq.write(postData); postReq.end(); } catch (e) { if (callback) callback(e.toString(), text, language, volume); } } function sayItGetSpeechPicoTTS(text, language, volume, callback) { if (!libs.fs) libs.fs = require('fs'); if (!libs.exec) libs.exec = require('child_process').exec; try { const cmd = 'pico2wave -l ' + language + ' -w ' + __dirname + '/say.wav "' + text + '"'; libs.exec(cmd, (error, stdout, stderr) => { if (error) { if (callback) callback('Cannot create (pico2wave) "say.wav": ' + error, text, language, volume); } else { libs.exec('lame ' + __dirname + '/say.wav ' + MP3FILE, (error, stdout, stderr) => { if (callback) callback(error ? 'Cannot create (lame) "say.mp3": ' + error : null, text, language, volume); }); } }); } catch (e) { if (callback) callback(e.toString(), text, language, volume); } } this.getLength = function (fileName, callback) { libs.fs = libs.fs || require('fs'); // create a new parser from a node ReadStream if (fileName === adapter.config.announce && adapter.config.annoDuration) { if (callback) callback(null, adapter.config.annoDuration - 1); return; } if (libs.fs.existsSync(fileName)) { try { const stat = libs.fs.statSync(fileName); const size = stat.size; if (callback) callback(null, Math.ceil(size / 4096)); } catch (e) { adapter.log.warn('Cannot read length of file ' + fileName); if (callback) callback(null, 0); } } else { if (callback) callback(null, 0); } }; const ENGINES = { 'baidu': sayItGetSpeechBaidu, 'google': sayItGetSpeechGoogle, 'yandex': sayItGetSpeechYandex, 'acapela': sayItGetSpeechAcapela, 'polly': sayItGetSpeechPolly, 'cloud': sayItGetSpeechCloud, 'PicoTTS': sayItGetSpeechPicoTTS }; this.sayItGetSpeech = function (text, language, volume, callback) { if (adapter.config.cache) { libs.path = libs.path || require('path'); const md5filename = libs.path.join(options.cacheDir, libs.crypto.createHash('md5').update(language + ';' + text).digest('hex') + '.mp3'); if (libs.fs.existsSync(md5filename)) { let cacheFileValid = true; if (adapter.config.cacheExpiryDays) { const fileStat = libs.fs.statSync(md5filename); if (fileStat.ctime && (new Date().getTime()-new Date(fileStat.ctime).getTime() > adapter.config.cacheExpiryDays*1000*60*60*24)) { cacheFileValid = false; adapter.log.info('Cached File expired, remove and re-generate'); libs.fs.unlinkSync(md5filename); } } if (cacheFileValid) { that.getLength(md5filename, (error, seconds) => { if (callback) callback(error, md5filename, language, volume, seconds); }); return; } } } if (sayitEngines[language] && sayitEngines[language].engine) { if (!sayitEngines[language].ssml) text = text.replace(/<\/?[-+\w\s'"=]+>/g, ''); // remove SSML if (!ENGINES[sayitEngines[language].engine]) { callback && callback('Engine ' + sayitEngines[language].engine + ' not yet supported.', text, language, volume, 0); } else { ENGINES[sayitEngines[language].engine](text, language, volume, (error, _text, _language, _volume, seconds) => cacheFile(error, _text, _language, _volume, seconds, callback)); } } else { sayItGetSpeechGoogle(text, language, volume, (error, _text, _language, _volume, seconds) => cacheFile(error, _text, _language, _volume, seconds, callback)); } }; return this; } module.exports = Text2Speech;