PageRenderTime 603ms CodeModel.GetById 1ms RepoModel.GetById 1ms app.codeStats 0ms

/client/tts.py

https://gitlab.com/leiftomas/jasper-client
Python | 711 lines | 706 code | 0 blank | 5 comment | 0 complexity | b243a3ae197a74a8295c56d89af0a64a MD5 | raw file
  1. # -*- coding: utf-8-*-
  2. """
  3. A Speaker handles audio output from Jasper to the user
  4. Speaker methods:
  5. say - output 'phrase' as speech
  6. play - play the audio in 'filename'
  7. is_available - returns True if the platform supports this implementation
  8. """
  9. import os
  10. import platform
  11. import re
  12. import tempfile
  13. import subprocess
  14. import pipes
  15. import logging
  16. import wave
  17. import urllib
  18. import urlparse
  19. import requests
  20. from abc import ABCMeta, abstractmethod
  21. import argparse
  22. import yaml
  23. try:
  24. import mad
  25. except ImportError:
  26. pass
  27. try:
  28. import gtts
  29. except ImportError:
  30. pass
  31. try:
  32. import pyvona
  33. except ImportError:
  34. pass
  35. import diagnose
  36. import jasperpath
  37. class AbstractTTSEngine(object):
  38. """
  39. Generic parent class for all speakers
  40. """
  41. __metaclass__ = ABCMeta
  42. @classmethod
  43. def get_config(cls):
  44. return {}
  45. @classmethod
  46. def get_instance(cls):
  47. config = cls.get_config()
  48. instance = cls(**config)
  49. return instance
  50. @classmethod
  51. @abstractmethod
  52. def is_available(cls):
  53. return diagnose.check_executable('aplay')
  54. def __init__(self, **kwargs):
  55. self._logger = logging.getLogger(__name__)
  56. @abstractmethod
  57. def say(self, phrase, *args):
  58. pass
  59. def play(self, filename):
  60. # FIXME: Use platform-independent audio-output here
  61. # See issue jasperproject/jasper-client#188
  62. cmd = ['aplay', '-D', 'plughw:1,0', str(filename)]
  63. self._logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  64. for arg in cmd]))
  65. with tempfile.TemporaryFile() as f:
  66. subprocess.call(cmd, stdout=f, stderr=f)
  67. f.seek(0)
  68. output = f.read()
  69. if output:
  70. self._logger.debug("Output was: '%s'", output)
  71. class AbstractMp3TTSEngine(AbstractTTSEngine):
  72. """
  73. Generic class that implements the 'play' method for mp3 files
  74. """
  75. @classmethod
  76. def is_available(cls):
  77. return (super(AbstractMp3TTSEngine, cls).is_available() and
  78. diagnose.check_python_import('mad'))
  79. def play_mp3(self, filename):
  80. mf = mad.MadFile(filename)
  81. with tempfile.NamedTemporaryFile(suffix='.wav') as f:
  82. wav = wave.open(f, mode='wb')
  83. wav.setframerate(mf.samplerate())
  84. wav.setnchannels(1 if mf.mode() == mad.MODE_SINGLE_CHANNEL else 2)
  85. # 4L is the sample width of 32 bit audio
  86. wav.setsampwidth(4L)
  87. frame = mf.read()
  88. while frame is not None:
  89. wav.writeframes(frame)
  90. frame = mf.read()
  91. wav.close()
  92. self.play(f.name)
  93. class DummyTTS(AbstractTTSEngine):
  94. """
  95. Dummy TTS engine that logs phrases with INFO level instead of synthesizing
  96. speech.
  97. """
  98. SLUG = "dummy-tts"
  99. @classmethod
  100. def is_available(cls):
  101. return True
  102. def say(self, phrase):
  103. self._logger.info(phrase)
  104. def play(self, filename):
  105. self._logger.debug("Playback of file '%s' requested")
  106. pass
  107. class EspeakTTS(AbstractTTSEngine):
  108. """
  109. Uses the eSpeak speech synthesizer included in the Jasper disk image
  110. Requires espeak to be available
  111. """
  112. SLUG = "espeak-tts"
  113. def __init__(self, voice='default+m3', pitch_adjustment=40,
  114. words_per_minute=160):
  115. super(self.__class__, self).__init__()
  116. self.voice = voice
  117. self.pitch_adjustment = pitch_adjustment
  118. self.words_per_minute = words_per_minute
  119. @classmethod
  120. def get_config(cls):
  121. # FIXME: Replace this as soon as we have a config module
  122. config = {}
  123. # HMM dir
  124. # Try to get hmm_dir from config
  125. profile_path = jasperpath.config('profile.yml')
  126. if os.path.exists(profile_path):
  127. with open(profile_path, 'r') as f:
  128. profile = yaml.safe_load(f)
  129. if 'espeak-tts' in profile:
  130. if 'voice' in profile['espeak-tts']:
  131. config['voice'] = profile['espeak-tts']['voice']
  132. if 'pitch_adjustment' in profile['espeak-tts']:
  133. config['pitch_adjustment'] = \
  134. profile['espeak-tts']['pitch_adjustment']
  135. if 'words_per_minute' in profile['espeak-tts']:
  136. config['words_per_minute'] = \
  137. profile['espeak-tts']['words_per_minute']
  138. return config
  139. @classmethod
  140. def is_available(cls):
  141. return (super(cls, cls).is_available() and
  142. diagnose.check_executable('espeak'))
  143. def say(self, phrase):
  144. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  145. with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
  146. fname = f.name
  147. cmd = ['espeak', '-v', self.voice,
  148. '-p', self.pitch_adjustment,
  149. '-s', self.words_per_minute,
  150. '-w', fname,
  151. phrase]
  152. cmd = [str(x) for x in cmd]
  153. self._logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  154. for arg in cmd]))
  155. with tempfile.TemporaryFile() as f:
  156. subprocess.call(cmd, stdout=f, stderr=f)
  157. f.seek(0)
  158. output = f.read()
  159. if output:
  160. self._logger.debug("Output was: '%s'", output)
  161. self.play(fname)
  162. os.remove(fname)
  163. class FestivalTTS(AbstractTTSEngine):
  164. """
  165. Uses the festival speech synthesizer
  166. Requires festival (text2wave) to be available
  167. """
  168. SLUG = 'festival-tts'
  169. @classmethod
  170. def is_available(cls):
  171. if (super(cls, cls).is_available() and
  172. diagnose.check_executable('text2wave') and
  173. diagnose.check_executable('festival')):
  174. logger = logging.getLogger(__name__)
  175. cmd = ['festival', '--pipe']
  176. with tempfile.SpooledTemporaryFile() as out_f:
  177. with tempfile.SpooledTemporaryFile() as in_f:
  178. logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  179. for arg in cmd]))
  180. subprocess.call(cmd, stdin=in_f, stdout=out_f,
  181. stderr=out_f)
  182. out_f.seek(0)
  183. output = out_f.read().strip()
  184. if output:
  185. logger.debug("Output was: '%s'", output)
  186. return ('No default voice found' not in output)
  187. return False
  188. def say(self, phrase):
  189. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  190. cmd = ['text2wave']
  191. with tempfile.NamedTemporaryFile(suffix='.wav') as out_f:
  192. with tempfile.SpooledTemporaryFile() as in_f:
  193. in_f.write(phrase)
  194. in_f.seek(0)
  195. with tempfile.SpooledTemporaryFile() as err_f:
  196. self._logger.debug('Executing %s',
  197. ' '.join([pipes.quote(arg)
  198. for arg in cmd]))
  199. subprocess.call(cmd, stdin=in_f, stdout=out_f,
  200. stderr=err_f)
  201. err_f.seek(0)
  202. output = err_f.read()
  203. if output:
  204. self._logger.debug("Output was: '%s'", output)
  205. self.play(out_f.name)
  206. class FliteTTS(AbstractTTSEngine):
  207. """
  208. Uses the flite speech synthesizer
  209. Requires flite to be available
  210. """
  211. SLUG = 'flite-tts'
  212. def __init__(self, voice=''):
  213. super(self.__class__, self).__init__()
  214. self.voice = voice if voice and voice in self.get_voices() else ''
  215. @classmethod
  216. def get_voices(cls):
  217. cmd = ['flite', '-lv']
  218. voices = []
  219. with tempfile.SpooledTemporaryFile() as out_f:
  220. subprocess.call(cmd, stdout=out_f)
  221. out_f.seek(0)
  222. for line in out_f:
  223. if line.startswith('Voices available: '):
  224. voices.extend([x.strip() for x in line[18:].split()
  225. if x.strip()])
  226. return voices
  227. @classmethod
  228. def get_config(cls):
  229. # FIXME: Replace this as soon as we have a config module
  230. config = {}
  231. # HMM dir
  232. # Try to get hmm_dir from config
  233. profile_path = jasperpath.config('profile.yml')
  234. if os.path.exists(profile_path):
  235. with open(profile_path, 'r') as f:
  236. profile = yaml.safe_load(f)
  237. if 'flite-tts' in profile:
  238. if 'voice' in profile['flite-tts']:
  239. config['voice'] = profile['flite-tts']['voice']
  240. return config
  241. @classmethod
  242. def is_available(cls):
  243. return (super(cls, cls).is_available() and
  244. diagnose.check_executable('flite') and
  245. len(cls.get_voices()) > 0)
  246. def say(self, phrase):
  247. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  248. cmd = ['flite']
  249. if self.voice:
  250. cmd.extend(['-voice', self.voice])
  251. cmd.extend(['-t', phrase])
  252. with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
  253. fname = f.name
  254. cmd.append(fname)
  255. with tempfile.SpooledTemporaryFile() as out_f:
  256. self._logger.debug('Executing %s',
  257. ' '.join([pipes.quote(arg)
  258. for arg in cmd]))
  259. subprocess.call(cmd, stdout=out_f, stderr=out_f)
  260. out_f.seek(0)
  261. output = out_f.read().strip()
  262. if output:
  263. self._logger.debug("Output was: '%s'", output)
  264. self.play(fname)
  265. os.remove(fname)
  266. class MacOSXTTS(AbstractTTSEngine):
  267. """
  268. Uses the OS X built-in 'say' command
  269. """
  270. SLUG = "osx-tts"
  271. @classmethod
  272. def is_available(cls):
  273. return (platform.system().lower() == 'darwin' and
  274. diagnose.check_executable('say') and
  275. diagnose.check_executable('afplay'))
  276. def say(self, phrase):
  277. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  278. cmd = ['say', str(phrase)]
  279. self._logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  280. for arg in cmd]))
  281. with tempfile.TemporaryFile() as f:
  282. subprocess.call(cmd, stdout=f, stderr=f)
  283. f.seek(0)
  284. output = f.read()
  285. if output:
  286. self._logger.debug("Output was: '%s'", output)
  287. def play(self, filename):
  288. cmd = ['afplay', str(filename)]
  289. self._logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  290. for arg in cmd]))
  291. with tempfile.TemporaryFile() as f:
  292. subprocess.call(cmd, stdout=f, stderr=f)
  293. f.seek(0)
  294. output = f.read()
  295. if output:
  296. self._logger.debug("Output was: '%s'", output)
  297. class PicoTTS(AbstractTTSEngine):
  298. """
  299. Uses the svox-pico-tts speech synthesizer
  300. Requires pico2wave to be available
  301. """
  302. SLUG = "pico-tts"
  303. def __init__(self, language="en-US"):
  304. super(self.__class__, self).__init__()
  305. self.language = language
  306. @classmethod
  307. def is_available(cls):
  308. return (super(cls, cls).is_available() and
  309. diagnose.check_executable('pico2wave'))
  310. @classmethod
  311. def get_config(cls):
  312. # FIXME: Replace this as soon as we have a config module
  313. config = {}
  314. # HMM dir
  315. # Try to get hmm_dir from config
  316. profile_path = jasperpath.config('profile.yml')
  317. if os.path.exists(profile_path):
  318. with open(profile_path, 'r') as f:
  319. profile = yaml.safe_load(f)
  320. if 'pico-tts' in profile and 'language' in profile['pico-tts']:
  321. config['language'] = profile['pico-tts']['language']
  322. return config
  323. @property
  324. def languages(self):
  325. cmd = ['pico2wave', '-l', 'NULL',
  326. '-w', os.devnull,
  327. 'NULL']
  328. with tempfile.SpooledTemporaryFile() as f:
  329. subprocess.call(cmd, stderr=f)
  330. f.seek(0)
  331. output = f.read()
  332. pattern = re.compile(r'Unknown language: NULL\nValid languages:\n' +
  333. r'((?:[a-z]{2}-[A-Z]{2}\n)+)')
  334. matchobj = pattern.match(output)
  335. if not matchobj:
  336. raise RuntimeError("pico2wave: valid languages not detected")
  337. langs = matchobj.group(1).split()
  338. return langs
  339. def say(self, phrase):
  340. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  341. with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
  342. fname = f.name
  343. cmd = ['pico2wave', '--wave', fname]
  344. if self.language not in self.languages:
  345. raise ValueError("Language '%s' not supported by '%s'",
  346. self.language, self.SLUG)
  347. cmd.extend(['-l', self.language])
  348. cmd.append(phrase)
  349. self._logger.debug('Executing %s', ' '.join([pipes.quote(arg)
  350. for arg in cmd]))
  351. with tempfile.TemporaryFile() as f:
  352. subprocess.call(cmd, stdout=f, stderr=f)
  353. f.seek(0)
  354. output = f.read()
  355. if output:
  356. self._logger.debug("Output was: '%s'", output)
  357. self.play(fname)
  358. os.remove(fname)
  359. class GoogleTTS(AbstractMp3TTSEngine):
  360. """
  361. Uses the Google TTS online translator
  362. Requires pymad and gTTS to be available
  363. """
  364. SLUG = "google-tts"
  365. def __init__(self, language='en'):
  366. super(self.__class__, self).__init__()
  367. self.language = language
  368. @classmethod
  369. def is_available(cls):
  370. return (super(cls, cls).is_available() and
  371. diagnose.check_python_import('gtts') and
  372. diagnose.check_network_connection())
  373. @classmethod
  374. def get_config(cls):
  375. # FIXME: Replace this as soon as we have a config module
  376. config = {}
  377. # HMM dir
  378. # Try to get hmm_dir from config
  379. profile_path = jasperpath.config('profile.yml')
  380. if os.path.exists(profile_path):
  381. with open(profile_path, 'r') as f:
  382. profile = yaml.safe_load(f)
  383. if ('google-tts' in profile and
  384. 'language' in profile['google-tts']):
  385. config['language'] = profile['google-tts']['language']
  386. return config
  387. @property
  388. def languages(self):
  389. langs = ['af', 'sq', 'ar', 'hy', 'ca', 'zh-CN', 'zh-TW', 'hr', 'cs',
  390. 'da', 'nl', 'en', 'eo', 'fi', 'fr', 'de', 'el', 'ht', 'hi',
  391. 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv', 'mk', 'no',
  392. 'pl', 'pt', 'ro', 'ru', 'sr', 'sk', 'es', 'sw', 'sv', 'ta',
  393. 'th', 'tr', 'vi', 'cy']
  394. return langs
  395. def say(self, phrase):
  396. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  397. if self.language not in self.languages:
  398. raise ValueError("Language '%s' not supported by '%s'",
  399. self.language, self.SLUG)
  400. tts = gtts.gTTS(text=phrase, lang=self.language)
  401. with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
  402. tmpfile = f.name
  403. tts.save(tmpfile)
  404. self.play_mp3(tmpfile)
  405. os.remove(tmpfile)
  406. class MaryTTS(AbstractTTSEngine):
  407. """
  408. Uses the MARY Text-to-Speech System (MaryTTS)
  409. MaryTTS is an open-source, multilingual Text-to-Speech Synthesis platform
  410. written in Java.
  411. Please specify your own server instead of using the demonstration server
  412. (http://mary.dfki.de:59125/) to save bandwidth and to protect your privacy.
  413. """
  414. SLUG = "mary-tts"
  415. def __init__(self, server="mary.dfki.de", port="59125", language="en_GB",
  416. voice="dfki-spike"):
  417. super(self.__class__, self).__init__()
  418. self.server = server
  419. self.port = port
  420. self.netloc = '{server}:{port}'.format(server=self.server,
  421. port=self.port)
  422. self.language = language
  423. self.voice = voice
  424. self.session = requests.Session()
  425. @property
  426. def languages(self):
  427. try:
  428. r = self.session.get(self._makeurl('/locales'))
  429. r.raise_for_status()
  430. except requests.exceptions.RequestException:
  431. self._logger.critical("Communication with MaryTTS server at %s " +
  432. "failed.", self.netloc)
  433. raise
  434. return r.text.splitlines()
  435. @property
  436. def voices(self):
  437. r = self.session.get(self._makeurl('/voices'))
  438. r.raise_for_status()
  439. return [line.split()[0] for line in r.text.splitlines()]
  440. @classmethod
  441. def get_config(cls):
  442. # FIXME: Replace this as soon as we have a config module
  443. config = {}
  444. # HMM dir
  445. # Try to get hmm_dir from config
  446. profile_path = jasperpath.config('profile.yml')
  447. if os.path.exists(profile_path):
  448. with open(profile_path, 'r') as f:
  449. profile = yaml.safe_load(f)
  450. if 'mary-tts' in profile:
  451. if 'server' in profile['mary-tts']:
  452. config['server'] = profile['mary-tts']['server']
  453. if 'port' in profile['mary-tts']:
  454. config['port'] = profile['mary-tts']['port']
  455. if 'language' in profile['mary-tts']:
  456. config['language'] = profile['mary-tts']['language']
  457. if 'voice' in profile['mary-tts']:
  458. config['voice'] = profile['mary-tts']['voice']
  459. return config
  460. @classmethod
  461. def is_available(cls):
  462. return (super(cls, cls).is_available() and
  463. diagnose.check_network_connection())
  464. def _makeurl(self, path, query={}):
  465. query_s = urllib.urlencode(query)
  466. urlparts = ('http', self.netloc, path, query_s, '')
  467. return urlparse.urlunsplit(urlparts)
  468. def say(self, phrase):
  469. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  470. if self.language not in self.languages:
  471. raise ValueError("Language '%s' not supported by '%s'"
  472. % (self.language, self.SLUG))
  473. if self.voice not in self.voices:
  474. raise ValueError("Voice '%s' not supported by '%s'"
  475. % (self.voice, self.SLUG))
  476. query = {'OUTPUT_TYPE': 'AUDIO',
  477. 'AUDIO': 'WAVE_FILE',
  478. 'INPUT_TYPE': 'TEXT',
  479. 'INPUT_TEXT': phrase,
  480. 'LOCALE': self.language,
  481. 'VOICE': self.voice}
  482. r = self.session.get(self._makeurl('/process', query=query))
  483. with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
  484. f.write(r.content)
  485. tmpfile = f.name
  486. self.play(tmpfile)
  487. os.remove(tmpfile)
  488. class IvonaTTS(AbstractMp3TTSEngine):
  489. """
  490. Uses the Ivona Speech Cloud Services.
  491. Ivona is a multilingual Text-to-Speech synthesis platform developed by
  492. Amazon.
  493. """
  494. SLUG = "ivona-tts"
  495. def __init__(self, access_key='', secret_key='', region=None,
  496. voice=None, speech_rate=None, sentence_break=None):
  497. super(self.__class__, self).__init__()
  498. self._pyvonavoice = pyvona.Voice(access_key, secret_key)
  499. self._pyvonavoice.codec = "mp3"
  500. if region:
  501. self._pyvonavoice.region = region
  502. if voice:
  503. self._pyvonavoice.voice_name = voice
  504. if speech_rate:
  505. self._pyvonavoice.speech_rate = speech_rate
  506. if sentence_break:
  507. self._pyvonavoice.sentence_break = sentence_break
  508. @classmethod
  509. def get_config(cls):
  510. # FIXME: Replace this as soon as we have a config module
  511. config = {}
  512. # HMM dir
  513. # Try to get hmm_dir from config
  514. profile_path = jasperpath.config('profile.yml')
  515. if os.path.exists(profile_path):
  516. with open(profile_path, 'r') as f:
  517. profile = yaml.safe_load(f)
  518. if 'ivona-tts' in profile:
  519. if 'access_key' in profile['ivona-tts']:
  520. config['access_key'] = \
  521. profile['ivona-tts']['access_key']
  522. if 'secret_key' in profile['ivona-tts']:
  523. config['secret_key'] = \
  524. profile['ivona-tts']['secret_key']
  525. if 'region' in profile['ivona-tts']:
  526. config['region'] = profile['ivona-tts']['region']
  527. if 'voice' in profile['ivona-tts']:
  528. config['voice'] = profile['ivona-tts']['voice']
  529. if 'speech_rate' in profile['ivona-tts']:
  530. config['speech_rate'] = \
  531. profile['ivona-tts']['speech_rate']
  532. if 'sentence_break' in profile['ivona-tts']:
  533. config['sentence_break'] = \
  534. profile['ivona-tts']['sentence_break']
  535. return config
  536. @classmethod
  537. def is_available(cls):
  538. return (super(cls, cls).is_available() and
  539. diagnose.check_python_import('pyvona') and
  540. diagnose.check_network_connection())
  541. def say(self, phrase):
  542. self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG)
  543. with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
  544. tmpfile = f.name
  545. self._pyvonavoice.fetch_voice(phrase, tmpfile)
  546. self.play_mp3(tmpfile)
  547. os.remove(tmpfile)
  548. def get_default_engine_slug():
  549. return 'osx-tts' if platform.system().lower() == 'darwin' else 'espeak-tts'
  550. def get_engine_by_slug(slug=None):
  551. """
  552. Returns:
  553. A speaker implementation available on the current platform
  554. Raises:
  555. ValueError if no speaker implementation is supported on this platform
  556. """
  557. if not slug or type(slug) is not str:
  558. raise TypeError("Invalid slug '%s'", slug)
  559. selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and
  560. engine.SLUG == slug, get_engines())
  561. if len(selected_engines) == 0:
  562. raise ValueError("No TTS engine found for slug '%s'" % slug)
  563. else:
  564. if len(selected_engines) > 1:
  565. print("WARNING: Multiple TTS engines found for slug '%s'. " +
  566. "This is most certainly a bug." % slug)
  567. engine = selected_engines[0]
  568. if not engine.is_available():
  569. raise ValueError(("TTS engine '%s' is not available (due to " +
  570. "missing dependencies, etc.)") % slug)
  571. return engine
  572. def get_engines():
  573. def get_subclasses(cls):
  574. subclasses = set()
  575. for subclass in cls.__subclasses__():
  576. subclasses.add(subclass)
  577. subclasses.update(get_subclasses(subclass))
  578. return subclasses
  579. return [tts_engine for tts_engine in
  580. list(get_subclasses(AbstractTTSEngine))
  581. if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG]
  582. if __name__ == '__main__':
  583. parser = argparse.ArgumentParser(description='Jasper TTS module')
  584. parser.add_argument('--debug', action='store_true',
  585. help='Show debug messages')
  586. args = parser.parse_args()
  587. logging.basicConfig()
  588. if args.debug:
  589. logger = logging.getLogger(__name__)
  590. logger.setLevel(logging.DEBUG)
  591. engines = get_engines()
  592. available_engines = []
  593. for engine in get_engines():
  594. if engine.is_available():
  595. available_engines.append(engine)
  596. disabled_engines = list(set(engines).difference(set(available_engines)))
  597. print("Available TTS engines:")
  598. for i, engine in enumerate(available_engines, start=1):
  599. print("%d. %s" % (i, engine.SLUG))
  600. print("")
  601. print("Disabled TTS engines:")
  602. for i, engine in enumerate(disabled_engines, start=1):
  603. print("%d. %s" % (i, engine.SLUG))
  604. print("")
  605. for i, engine in enumerate(available_engines, start=1):
  606. print("%d. Testing engine '%s'..." % (i, engine.SLUG))
  607. engine.get_instance().say("This is a test.")
  608. print("Done.")