1 | const ansiEscapes = require('ansi-escapes');
|
2 | const chalk = require('chalk')
|
3 | const Table = require('cli-table3');
|
4 |
|
5 | const docs = chalk`
|
6 | You can use the {blue wait:1234} modifier to add a custom delay (in milliseconds) between specific words.
|
7 | e.g. {cyan $ vox echo go wait:1200 helium}
|
8 | `
|
9 |
|
10 | function run() {
|
11 | const prefix = chalk`{bgYellow.black λ } `
|
12 | const voicepacks = require('fs').readdirSync(require('path').resolve(__dirname, 'assets', 'voicepacks'))
|
13 | if (!voicepacks || !voicepacks.length) {
|
14 | console.log(chalk`${prefix}{bgRed.black ERROR} No voicepacks found`)
|
15 | return
|
16 | }
|
17 |
|
18 |
|
19 | const program = require('commander')
|
20 | program
|
21 | .version('0.1.0')
|
22 | .addOption(new program.Option('-v, --voice <voice>', 'Select voice').default(voicepacks[0]).choices(voicepacks))
|
23 | .addOption(new program.Option('-l, --list [letter]', 'List words'))
|
24 | .addOption(new program.Option('-s, --search <search>', 'Search in available words'))
|
25 | .addOption(new program.Option('-c, --compact', 'Compact result'))
|
26 | .addOption(new program.Option('-r, --repeat <n>', 'Repeat n times'))
|
27 | .addOption(new program.Option('-p, --pause <n>', 'Pause between repeats').default(1400))
|
28 | .addOption(new program.Option('-d, --delay <n>', 'Delay between words').default(350))
|
29 | .addOption(new program.Option('-x, --random [n]', 'Pick random (n) words'))
|
30 | .arguments('[words...]')
|
31 | .description(prefix + 'Speech synthensizing from the cli using Half-Life and Black Mesa VOX soundpacks', {
|
32 | words: 'words to say'
|
33 | })
|
34 | .addHelpText('after', docs)
|
35 | .action((words, options, ...rest) => {
|
36 |
|
37 | const voice = options.voice
|
38 | const getFilepath = (n) => './assets/voicepacks/' + voice + '/' + n + '.wav'
|
39 |
|
40 | if (options.list || options.search) {
|
41 | var table = new Table({
|
42 | head: options.compact ? ['Letter', 'Words'] : ['Letter', 'Word', 'filename'],
|
43 | colWidths: options.compact ? [8, 40] : [8, 18],
|
44 | wordWrap:true
|
45 | });
|
46 | const letters = {}
|
47 |
|
48 | const files = require('fs').readdirSync(require('path').resolve(__dirname, 'assets', 'voicepacks', voice))
|
49 | const search = (options.search || typeof options.list === 'string') && new RegExp(options.search ? options.search : `^${options.list}`, 'gi')
|
50 | const wordsList = files
|
51 | .filter(f => f.match(/\.wav$/)).map(f => f.replace(/\.wav$/, '')).sort()
|
52 | .filter(word => !search || word.match(search))
|
53 |
|
54 |
|
55 | wordsList.map(word => letters[word[0]] = [...letters[word[0]] || [], word])
|
56 | Object.keys(letters).sort().map(letter => {
|
57 | const letterWords = letters[letter]
|
58 | const compact = options.compact && letterWords.join(', ')
|
59 | table.push([
|
60 | ...compact
|
61 | ? [letter.toUpperCase(), { content: compact }]
|
62 | : [
|
63 | {
|
64 | rowSpan: letterWords.length,
|
65 | content: letter.toUpperCase()
|
66 | },
|
67 | letterWords[0], getFilepath(letterWords[0])]
|
68 | ])
|
69 |
|
70 | if (!compact)
|
71 | letterWords.map((word, i) => i && table.push([word, getFilepath(word)]))
|
72 | })
|
73 | table.push([{ colSpan: options.compact ? 2 : 3, content: `Total${search ? ' results' : ''}: ${wordsList.length}` }])
|
74 |
|
75 | process.stdout.write(table.toString());
|
76 | process.stdout.write('\n');
|
77 | } else {
|
78 | if (!words || !words.length) {
|
79 | if (options.random) {
|
80 |
|
81 | const files = require('fs').readdirSync(require('path').resolve(__dirname, 'assets', voice))
|
82 | const wordsList = files
|
83 | .filter(f => f.match(/\.wav$/)).map(f => f.replace(/\.wav$/, '')).sort(() => Math.random() > 0.5 ? 1 : -1)
|
84 | const wordsCount = +options.random > 1 ? options.random : Math.floor(Math.random() * 12)
|
85 | for (let i = 0; i < wordsCount; ++i) words.push(wordsList[i])
|
86 | } else {
|
87 | words = Math.random() > 0.7 ? 'bloop wait:1200 no'.split(' ') : 'bloop must command'.split(' ')
|
88 | }
|
89 | }
|
90 |
|
91 | const player = require('node-wav-player');
|
92 | var wavFileInfo = require('wav-file-info');
|
93 |
|
94 | let times = 0
|
95 | let word
|
96 | const pause = options.pause
|
97 |
|
98 | const started = Date.now()
|
99 | const compact = options.compact
|
100 |
|
101 | !compact && console.log(chalk`${prefix}Synthesizing speech {dim — using {green ${options.voice}} voicepack}`)
|
102 | !compact && options.repeat && line(chalk`\n${prefix}{dim Repeating ${options.repeat} times}`)
|
103 | !compact && line(`${prefix}${words.join(' ')}`)
|
104 |
|
105 | const playNext = async () => {
|
106 | if (!word && word !== 0) word = 0
|
107 | else word++
|
108 |
|
109 | !compact && clear()
|
110 |
|
111 | const timerDisplay = async (time, interval = 50, fn) => {
|
112 | process.stdout.write(ansiEscapes.eraseLines(2))
|
113 | fn()
|
114 | await new Promise(r => setTimeout(r, interval))
|
115 | if (time - (interval * 5) > 0) timerDisplay(time - interval, interval, fn)
|
116 | }
|
117 |
|
118 | const done = async () => {
|
119 | if (options.repeat && ++times < options.repeat) {
|
120 | word = false
|
121 | !compact && clear()
|
122 | !compact && options.repeat && line(chalk`{dim ${prefix}Cycle ${times} of ${options.repeat} complete in ${Date.now() - started}ms. (CTRL+C to stop)}`)
|
123 |
|
124 | const then = Date.now()
|
125 | !compact && line(chalk`${prefix}{dim.green "${words.join(' ')}"}`)
|
126 | !compact && line(chalk`${prefix}{dim Repeating in ${options.pause}ms}`)
|
127 | !compact && await timerDisplay(options.pause, 50, () => {
|
128 | console.log(chalk`${prefix}{dim Repeating in ${options.pause - (Date.now() - then)}ms...}`)
|
129 | })
|
130 | await new Promise(r => setTimeout(r, options.pause))
|
131 | playNext()
|
132 | } else {
|
133 | !compact && clear()
|
134 | line(chalk`${prefix}{green "${words.join(' ')}}"`)
|
135 | options.repeat
|
136 | ? line(chalk`${prefix}{dim Completed ${options.repeat} cycles in ${Date.now() - started}ms.}`)
|
137 | : line(chalk`${prefix}{dim Complete in ${Date.now() - started}ms.}`)
|
138 | }
|
139 | }
|
140 |
|
141 | !compact && options.repeat && line(chalk`${prefix}{dim Cycle ${times + 1} of ${options.repeat}... (CTRL+C to stop)}`)
|
142 | !compact && line(chalk`${prefix}{dim "${words.map((w, i) => i === word ? chalk.reset.blue.underline(w) : i < word ? chalk.dim.green(w) : chalk.dim(w)).join(' ')}}{dim "}`);
|
143 |
|
144 | const sayWord = words[word]
|
145 | if (!sayWord) done()
|
146 | else {
|
147 |
|
148 | const filepath = getFilepath(sayWord)
|
149 | const delay = +options.delay
|
150 |
|
151 | const [, action, params] = sayWord.match(/^(.*):(.*)$/i) || []
|
152 |
|
153 | if (action) {
|
154 | if (action === 'wait') {
|
155 |
|
156 | !compact && line(chalk`${prefix}{dim Waiting ${+params}ms...}`);
|
157 | await new Promise(r => setTimeout(r, +params))
|
158 | playNext()
|
159 | }
|
160 | } else
|
161 | try {
|
162 | wavFileInfo
|
163 | .infoByFilename(filepath, async (err, info) => {
|
164 | if (err) {
|
165 | throw err;
|
166 | }
|
167 |
|
168 | const duration = Math.floor(info.duration * 1000)
|
169 | const waitFor = Math.floor(info.duration * 1000) - 350
|
170 |
|
171 | !compact && line(chalk`${prefix}{dim Playing "${sayWord}" — ${duration}ms...}`);
|
172 |
|
173 | const start = Date.now()
|
174 | player.play({ path: filepath })
|
175 | .then(async () => {
|
176 | if (word < words.length - 1) {
|
177 | const end = Date.now()
|
178 | const diff = end - start
|
179 |
|
180 | await new Promise((r) => setTimeout(r, waitFor - diff))
|
181 | delay && await new Promise((r) => setTimeout(r, delay))
|
182 | playNext()
|
183 | } else {
|
184 | done()
|
185 | }
|
186 | })
|
187 | });
|
188 | } catch (error) {
|
189 | !compact && clear()
|
190 | line(chalk`${prefix}{dim "${words.map((w, i) => i === word ? chalk.yellow.underline(w) : i < word ? chalk.dim.green(w) : chalk.dim(w)).join(' ')}"}`)
|
191 | line(chalk`${prefix}{bgRed.black ERROR} "${sayWord}" ${error}`);
|
192 | }
|
193 | }
|
194 |
|
195 | }
|
196 |
|
197 | playNext()
|
198 | }
|
199 | });
|
200 | program.parse(process.argv);
|
201 |
|
202 | }
|
203 | module.exports = {
|
204 | run
|
205 |
|
206 | }
|
207 |
|
208 | const state = {}
|
209 |
|
210 | const line = (input) => {
|
211 | state.lines = (state.lines || 0) + input.split('\n').length
|
212 | process.stdout.write(input + '\n')
|
213 | }
|
214 |
|
215 | const overline = (input) => {
|
216 | state.lines = ((state.lines || 0) + input.split('\n').length) - 1
|
217 | process.stdout.write(ansiEscapes.eraseLines(2))
|
218 | process.stdout.write(input + '\n')
|
219 | }
|
220 |
|
221 | const clear = () => {
|
222 | if (state.lines)
|
223 | process.stdout.write(ansiEscapes.eraseLines(state.lines + 1))
|
224 | state.lines = 0
|
225 | }
|
226 |
|
227 | const myRun = async () => {
|
228 | var table = new Table({
|
229 | style:{head:[],border:[]},
|
230 | colWidths: [14, 18],
|
231 | wordWrap:true
|
232 | });
|
233 |
|
234 | table.push(['playing', 'wav', './assets/vox/wav.wav', 12.32 + 'ms'])
|
235 | line(table.toString())
|
236 |
|
237 |
|
238 | }
|