- docomo Developer supportでAPIキーを作成する
 - 以下のコマンドを実行
 
npm install
export DOCOMO_API_KEY=XXXXXXXXXXXX
./playbook-to-voices 豚野郎_sm30193805.csv -p 豚野郎_preset.csv -o ./talk.wav
| .env | |
| node_modules | 
| 8.1.2 | 
npm install
export DOCOMO_API_KEY=XXXXXXXXXXXX
./playbook-to-voices 豚野郎_sm30193805.csv -p 豚野郎_preset.csv -o ./talk.wav
| const debug = require('debug')('ffmpeg') | |
| const ffmpeg = require('fluent-ffmpeg') | |
| ffmpeg.prototype._prepare = (function (org) { | |
| return function(callback, readMetadata) { | |
| org.call(this, (err, args) => { | |
| debug(args.join(' ')) | |
| callback(err, args) | |
| }, readMetadata) | |
| } | |
| }(ffmpeg.prototype._prepare)) | |
| module.exports = ffmpeg | 
| const debug = require('debug')('fetch') | |
| const fetch = require('isomorphic-fetch') | |
| module.exports = async (url, opts) => { | |
| debug(`${opts.method} ${url}`) | |
| const response = await fetch(url, opts) | |
| debug(`${response.status} ${response.statusText}`) | |
| if (!response.ok) { | |
| debug(response.headers) | |
| } | |
| return response | |
| } | 
| { | |
| "name": "playbook-to-voices", | |
| "version": "0.1.0", | |
| "lockfileVersion": 1, | |
| "requires": true, | |
| "dependencies": { | |
| "async": { | |
| "version": "2.5.0", | |
| "resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz", | |
| "integrity": "sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==", | |
| "requires": { | |
| "lodash": "4.17.4" | |
| } | |
| }, | |
| "bluebird": { | |
| "version": "3.5.0", | |
| "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", | |
| "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw=" | |
| }, | |
| "commander": { | |
| "version": "2.11.0", | |
| "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", | |
| "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" | |
| }, | |
| "csv-parse": { | |
| "version": "1.2.0", | |
| "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-1.2.0.tgz", | |
| "integrity": "sha1-BHtzhoq5qFdG6IX2N/ntD7ZFpCU=" | |
| }, | |
| "debug": { | |
| "version": "2.6.8", | |
| "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", | |
| "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", | |
| "requires": { | |
| "ms": "2.0.0" | |
| } | |
| }, | |
| "encoding": { | |
| "version": "0.1.12", | |
| "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", | |
| "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", | |
| "requires": { | |
| "iconv-lite": "0.4.18" | |
| } | |
| }, | |
| "fluent-ffmpeg": { | |
| "version": "2.1.2", | |
| "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz", | |
| "integrity": "sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=", | |
| "requires": { | |
| "async": "2.5.0", | |
| "which": "1.2.14" | |
| } | |
| }, | |
| "iconv-lite": { | |
| "version": "0.4.18", | |
| "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.18.tgz", | |
| "integrity": "sha512-sr1ZQph3UwHTR0XftSbK85OvBbxe/abLGzEnPENCQwmHf7sck8Oyu4ob3LgBxWWxRoM+QszeUyl7jbqapu2TqA==" | |
| }, | |
| "is-stream": { | |
| "version": "1.1.0", | |
| "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", | |
| "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" | |
| }, | |
| "isexe": { | |
| "version": "2.0.0", | |
| "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", | |
| "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" | |
| }, | |
| "isomorphic-fetch": { | |
| "version": "2.2.1", | |
| "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", | |
| "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", | |
| "requires": { | |
| "node-fetch": "1.7.1", | |
| "whatwg-fetch": "2.0.3" | |
| } | |
| }, | |
| "lodash": { | |
| "version": "4.17.4", | |
| "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", | |
| "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" | |
| }, | |
| "ms": { | |
| "version": "2.0.0", | |
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", | |
| "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" | |
| }, | |
| "node-fetch": { | |
| "version": "1.7.1", | |
| "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.1.tgz", | |
| "integrity": "sha512-j8XsFGCLw79vWXkZtMSmmLaOk9z5SQ9bV/tkbZVCqvgwzrjAGq66igobLofHtF63NvMTp2WjytpsNTGKa+XRIQ==", | |
| "requires": { | |
| "encoding": "0.1.12", | |
| "is-stream": "1.1.0" | |
| } | |
| }, | |
| "ssml-builder": { | |
| "version": "0.2.4", | |
| "resolved": "https://registry.npmjs.org/ssml-builder/-/ssml-builder-0.2.4.tgz", | |
| "integrity": "sha1-dN+9OPmvU+0q0mlKWDb09mY38WM=" | |
| }, | |
| "uuid": { | |
| "version": "3.1.0", | |
| "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", | |
| "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" | |
| }, | |
| "whatwg-fetch": { | |
| "version": "2.0.3", | |
| "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", | |
| "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" | |
| }, | |
| "which": { | |
| "version": "1.2.14", | |
| "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz", | |
| "integrity": "sha1-mofEN48D6CfOyvGs31bHNsAcFOU=", | |
| "requires": { | |
| "isexe": "2.0.0" | |
| } | |
| } | |
| } | |
| } | 
| { | |
| "name": "playbook-to-voices", | |
| "version": "0.1.0", | |
| "description": "Convert playbook to voices with VOICELOAD(c)", | |
| "main": "index.js", | |
| "scripts": { | |
| "test": "echo \"Error: no test specified\" && exit 1" | |
| }, | |
| "keywords": [ | |
| "text2speech", | |
| "text_to_speech", | |
| "speechsynthesize" | |
| ], | |
| "author": "Leko <[email protected]>", | |
| "license": "MIT", | |
| "dependencies": { | |
| "bluebird": "^3.5.0", | |
| "commander": "^2.11.0", | |
| "csv-parse": "^1.2.0", | |
| "debug": "^2.6.8", | |
| "fluent-ffmpeg": "^2.1.2", | |
| "isomorphic-fetch": "^2.2.1", | |
| "ssml-builder": "^0.2.4", | |
| "uuid": "^3.1.0" | |
| } | |
| } | 
| #!/usr/bin/env node | |
| const fs = require('fs') | |
| const querystring = require('querystring') | |
| const commander = require('commander') | |
| const Promise = require('bluebird') | |
| const uuid = require('uuid/v4') | |
| const parse = Promise.promisify(require('csv-parse')) | |
| const debug = require('debug') | |
| const SSML = require('ssml-builder') | |
| const ffmpeg = require('./debuggable-fluent-ffmpeg') | |
| const fetch = require('./debuggable-isomorphic-fetch') | |
| const pkg = require('./package.json') | |
| const unlink = Promise.promisify(fs.unlink) | |
| const readFile = Promise.promisify(fs.readFile) | |
| const writeFile = Promise.promisify(fs.writeFile) | |
| const PRESET_NAME_DEFAULT = 'default' | |
| const PRESET_DEFAULT = { rate: 1, pitch: 1, range: 1, volume: 1 } | |
| SSML.prototype.voice = function (name, text) { | |
| this._elements.push(`<voice name="${name}">` + text + '</voice>') | |
| return this | |
| } | |
| SSML.prototype.ssml = (function (org) { | |
| return function () { | |
| const xmlPrefix = '<?xml version="1.0" encoding="utf-8" ?>\n' | |
| return xmlPrefix + org.call(this) | |
| .replace('<speak>', '<speak version="1.1">') | |
| .replace(/> </g, '><') | |
| } | |
| })(SSML.prototype.ssml) | |
| const parseCSV = async (path) => { | |
| debug('playbook:csv')(path) | |
| return readFile(path, 'utf-8').then(parse) | |
| } | |
| const parsePlaybook = async (path) => { | |
| const playbook = await parseCSV(path) | |
| return playbook.slice(1) | |
| } | |
| const parsePresets = async (presetPath) => { | |
| const presetRows = await parseCSV(presetPath) | |
| return presetRows.slice(1).reduce((acc, row) => { | |
| const [ voice, name, rate, pitch, range, volume ] = row | |
| debug('playbook:preset')(`${voice}(${name || PRESET_NAME_DEFAULT}): ${JSON.stringify({ rate, pitch, range, volume })}`) | |
| return Object.assign(acc, { | |
| [voice]: { | |
| [name || PRESET_NAME_DEFAULT]: Object.assign({}, PRESET_DEFAULT, { rate, pitch, range, volume }) | |
| } | |
| }) | |
| }, {}) | |
| } | |
| const appendVoice = (ssml, voice, presetName, txt, presets) => { | |
| const map = { | |
| 月読アイ: (ssml, txt) => ssml.voice('anzu', txt), | |
| 弦巻マキ: (ssml, txt) => ssml.voice('maki', txt), | |
| 結月ゆかり: (ssml, txt) => ssml.voice('sumire', txt), | |
| } | |
| let preset = PRESET_DEFAULT | |
| if (!map[voice]) { | |
| throw new Error(`Unknown voice: ${voice}`) | |
| } | |
| if (presets[voice] && presets[voice][presetName || PRESET_NAME_DEFAULT]) { | |
| preset = presets[voice][presetName || PRESET_NAME_DEFAULT] | |
| } | |
| const filteredPreset = Object.entries(preset).filter(([name, val]) => !!val) | |
| if (filteredPreset.length <= 0) { | |
| throw new Error(`Empty preset: ${voice}, ${presetName || PRESET_NAME_DEFAULT}`) | |
| } | |
| const attribtues = filteredPreset | |
| .map(([name, val]) => `${name}="${val}"`) | |
| .join(' ') | |
| debug('playbook:voice')(`${voice}(${attribtues})「${txt}」`) | |
| map[voice](ssml, `<prosody ${attribtues}>${txt}</prosody>`) | |
| return ssml | |
| } | |
| const generatePresets = async (presetPath) => { | |
| if (presetPath) { | |
| return parsePresets(presetPath) | |
| } else { | |
| return Promise.resolve({}) | |
| } | |
| } | |
| const toSSML = (genPresets) => async (voices) => { | |
| const presets = await genPresets | |
| const ssml = voices.reduce((acc, [voice, presetName, text]) => { | |
| return appendVoice(acc, voice, presetName, text, presets) | |
| }, new SSML()) | |
| return ssml.ssml() | |
| } | |
| const textToSpeech = async (ssml) => { | |
| const ENDPOINT = 'https://api.apigw.smt.docomo.ne.jp/aiTalk/v1/textToSpeech' | |
| const query = querystring.stringify({ | |
| APIKEY: process.env.DOCOMO_API_KEY, | |
| }) | |
| return fetch(`${ENDPOINT}?${query}`, { | |
| method: 'POST', | |
| body: ssml, | |
| headers: { | |
| 'Content-Type': 'application/ssml+xml', | |
| 'Accept': 'audio/L16', | |
| } | |
| }) | |
| } | |
| const storeTemporary = async (response) => { | |
| if (response.ok) { | |
| const path = `/tmp/${uuid()}` | |
| debug('playbook:storeTemporary')(path) | |
| return response.buffer() | |
| .then(buff => writeFile(path, buff)) | |
| .then(() => path) | |
| } else { | |
| return response.text() | |
| .then(text => Promise.reject(text)) | |
| } | |
| } | |
| const toWav = (destPath) => async (pcmPath) => { | |
| return new Promise((resolve, reject) => { | |
| const cmd = ffmpeg() | |
| .input(pcmPath) | |
| .inputOptions(['-ac 1', '-ar 16000']) | |
| .inputFormat('s16be') | |
| .output(destPath) | |
| .on('end', () => { | |
| console.log(destPath) | |
| unlink(pcmPath).then(resolve) | |
| }) | |
| .on('error', reject) | |
| cmd.run() | |
| }) | |
| } | |
| commander | |
| .version(pkg.version) | |
| .arguments('<playbook>') | |
| .option('-p, --presets [path]', 'Define prosody presets') | |
| .option('-o, --output [path]', 'Set output path') | |
| .action((playbook) => { | |
| if (!playbook) { | |
| commander.outputHelp() | |
| return | |
| } | |
| const output = commander.output || playbook.replace('.csv', '') + '.wav' | |
| parsePlaybook(playbook) | |
| .then(toSSML(generatePresets(commander.presets))) | |
| .then(textToSpeech) | |
| .then(storeTemporary) | |
| .then(toWav(output)) | |
| .catch(e => { | |
| console.error(e) | |
| process.exit(1) | |
| }) | |
| }) | |
| .parse(process.argv) | |
| voice | preset | text | |
|---|---|---|---|
| 弦巻マキ | セヤナー | グレートエレキファイア | 
| voice | name | rate | pitch | range | volume | |
|---|---|---|---|---|---|---|
| 弦巻マキ | セヤナー | 0.5 | 2.0 | 2.0 | 
| voice | name | rate | pitch | range | volume | |
|---|---|---|---|---|---|---|
| ゆっくり霊夢 | default | 1 | ||||
| 弦巻マキ | default | 1.4 | ||||
| 結月ゆかり | default | 1.4 | 1.2 | |||
| 月読アイ | default | 1.4 | 
| voice,preset,text | |
| 結月ゆかり,,皆さんこんにちは、結月ゆかりです | |
| 弦巻マキ,,"<phoneme ph=""ツル’/マ’キ"">弦巻</phoneme>マキです" | |
| 月読アイ,,ゆっくり霊夢です | |
| 結月ゆかり,,突然ですけど私、スーパーハカーになりました! | |
| 月読アイ,,この人いきなり何言ってんだ… | |
| 弦巻マキ,,なろうと思って簡単になれるものじゃないぞ | |
| 弦巻マキ,,あとハカーじゃなくてハッカーね | |
| 結月ゆかり,,ゆかりさんの華麗なハッキング技術で | |
| 結月ゆかり,,お前たちの個人情報を丸裸にしてやる! | |
| 結月ゆかり,,具体的には<phoneme ph=""パソコン"">PC</phoneme>の<phoneme ph=""ディー"">D</phoneme>ドライブの中身を晒してやる! | |
| 月読アイ,,やめてください!社会的に死ぬ人が出るのでやめてください! |