diff options
| author | schneefux <schneefux+commit@schneefux.xyz> | 2014-09-24 16:59:48 +0200 |
|---|---|---|
| committer | schneefux <schneefux+commit@schneefux.xyz> | 2014-09-26 13:34:48 +0200 |
| commit | 6e55abfb09c611abf961cbd4c53e286b39ff6218 (patch) | |
| tree | c75e47e25e218d7a1b141a0947ad93ac55a9f81a /client/tts.py | |
| parent | ee27d799fe83bdcf88093649f92c7127d4236cf5 (diff) | |
| download | jasper-client-6e55abfb09c611abf961cbd4c53e286b39ff6218.tar.gz jasper-client-6e55abfb09c611abf961cbd4c53e286b39ff6218.zip | |
Rename `speaker.py` to `tts.py` to match `stt.py` and also change class names
Diffstat (limited to 'client/tts.py')
| -rw-r--r-- | client/tts.py | 361 |
1 files changed, 361 insertions, 0 deletions
diff --git a/client/tts.py b/client/tts.py new file mode 100644 index 0000000..4bda53d --- /dev/null +++ b/client/tts.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8-*- +""" +A Speaker handles audio output from Jasper to the user + +Speaker methods: + say - output 'phrase' as speech + play - play the audio in 'filename' + is_available - returns True if the platform supports this implementation +""" +import os +import platform +import re +import sys +import tempfile +import subprocess +import pipes +import logging +from abc import ABCMeta, abstractmethod +from distutils.spawn import find_executable + +import yaml +import argparse + +import pyaudio +import wave +try: + import mad + import gtts +except ImportError: + pass + +class AbstractTTSEngine(object): + """ + Generic parent class for all speakers + """ + __metaclass__ = ABCMeta + + @classmethod + @abstractmethod + def is_available(cls): + return (find_executable('aplay') is not None) + + def __init__(self, **kwargs): + self._logger = logging.getLogger(__name__) + + @abstractmethod + def say(self, phrase, *args): + pass + + def play(self, filename): + # FIXME: Use platform-independent audio-output here + # See issue jasperproject/jasper-client#188 + cmd = ['aplay', str(filename)] + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + with tempfile.TemporaryFile() as f: + subprocess.call(cmd, stdout=f, stderr=f) + f.seek(0) + output = f.read() + if output: + self._logger.debug("Output was: '%s'", output) + +class AbstractMp3TTSEngine(AbstractTTSEngine): + """ + Generic class that implements the 'play' method for mp3 files + """ + @classmethod + def is_available(cls): + return (super(AbstractMp3TTSEngine, cls).is_available() and 'mad' in sys.modules.keys()) + + def play_mp3(self, filename): + mf = mad.MadFile(filename) + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + wav = wave.open(f, mode='wb') + wav.setframerate(mf.samplerate()) + wav.setnchannels(1 if mf.mode() == mad.MODE_SINGLE_CHANNEL else 2) + wav.setsampwidth(pyaudio.get_sample_size(pyaudio.paInt32)) + frame = mf.read() + while frame is not None: + wav.writeframes(frame) + frame = mf.read() + wav.close() + self.play(f.name) + +class DummyTTS(AbstractTTSEngine): + """ + Dummy TTS engine that logs phrases with INFO level instead of synthesizing + speech. + """ + + SLUG = "dummy-tts" + + @classmethod + def is_available(cls): + return True + + def say(self, phrase): + self._logger.info(phrase) + + def play(self, filename): + self._logger.debug("Playback of file '%s' requested") + pass + +class EspeakTTS(AbstractTTSEngine): + """ + Uses the eSpeak speech synthesizer included in the Jasper disk image + Requires espeak to be available + """ + + SLUG = "espeak-tts" + + def __init__(self, voice='default+m3', pitch_adjustment=40, words_per_minute=160): + super(self.__class__, self).__init__() + self.voice = voice + self.pitch_adjustment = pitch_adjustment + self.words_per_minute = words_per_minute + + @classmethod + def is_available(cls): + return (super(cls, cls).is_available() and find_executable('espeak') is not None) + + def say(self, phrase): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + fname = f.name + cmd = ['espeak', '-v', self.voice, + '-p', self.pitch_adjustment, + '-s', self.words_per_minute, + '-w', fname, + phrase] + cmd = [str(x) for x in cmd] + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + with tempfile.TemporaryFile() as f: + subprocess.call(cmd, stdout=f, stderr=f) + f.seek(0) + output = f.read() + if output: + self._logger.debug("Output was: '%s'", output) + self.play(fname) + os.remove(fname) + +class FestivalTTS(AbstractTTSEngine): + """ + Uses the festival speech synthesizer + Requires festival (text2wave) to be available + """ + + SLUG = 'festival-tts' + + @classmethod + def is_available(cls): + if super(cls, cls).is_available() and find_executable('text2wave') is not None and find_executable('festival') is not None: + logger = logging.getLogger(__name__) + cmd = ['festival', '--pipe'] + with tempfile.SpooledTemporaryFile() as out_f: + with tempfile.SpooledTemporaryFile() as in_f: + logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + subprocess.call(cmd, stdin=in_f, stdout=out_f, stderr=out_f) + out_f.seek(0) + output = out_f.read().strip() + if output: + logger.debug("Output was: '%s'", output) + return ('No default voice found' not in output) + return False + + def say(self, phrase): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) + cmd = ['text2wave'] + with tempfile.NamedTemporaryFile(suffix='.wav') as out_f: + with tempfile.SpooledTemporaryFile() as in_f: + in_f.write(phrase) + in_f.seek(0) + with tempfile.SpooledTemporaryFile() as err_f: + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + subprocess.call(cmd, stdin=in_f, stdout=out_f, stderr=err_f) + err_f.seek(0) + output = err_f.read() + if output: + self._logger.debug("Output was: '%s'", output) + self.play(out_f.name) + +class MacOSXTTS(AbstractTTSEngine): + """ + Uses the OS X built-in 'say' command + """ + + SLUG = "osx-tts" + + @classmethod + def is_available(cls): + return (platform.system() == 'darwin' and find_executable('say') is not None and find_executable('afplay') is not None) + + def say(self, phrase): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) + cmd = ['say', str(phrase)] + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + with tempfile.TemporaryFile() as f: + subprocess.call(cmd, stdout=f, stderr=f) + f.seek(0) + output = f.read() + if output: + self._logger.debug("Output was: '%s'", output) + + def play(self, filename): + cmd = ['afplay', str(filename)] + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + with tempfile.TemporaryFile() as f: + subprocess.call(cmd, stdout=f, stderr=f) + f.seek(0) + output = f.read() + if output: + self._logger.debug("Output was: '%s'", output) + +class PicoTTS(AbstractTTSEngine): + """ + Uses the svox-pico-tts speech synthesizer + Requires pico2wave to be available + """ + + SLUG = "pico-tts" + + def __init__(self, language="en-US"): + super(self.__class__, self).__init__() + self.language = language + + @classmethod + def is_available(cls): + return (super(cls, cls).is_available() and find_executable('pico2wave') is not None) + + @property + def languages(self): + cmd = ['pico2wave', '-l', 'NULL', + '-w', os.devnull, + 'NULL'] + with tempfile.SpooledTemporaryFile() as f: + subprocess.call(cmd, stderr=f) + f.seek(0) + output = f.read() + pattern = re.compile(r'Unknown language: NULL\nValid languages:\n((?:[a-z]{2}-[A-Z]{2}\n)+)') + matchobj = pattern.match(output) + if not matchobj: + raise RuntimeError("pico2wave: valid languages not detected") + langs = matchobj.group(1).split() + return langs + + def say(self, phrase): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + fname = f.name + cmd = ['pico2wave', '--wave', fname] + if self.language not in self.languages: + raise ValueError("Language '%s' not supported by '%s'", self.language, self.SLUG) + cmd.extend(['-l', self.language]) + cmd.append(phrase) + self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) for arg in cmd])) + with tempfile.TemporaryFile() as f: + subprocess.call(cmd, stdout=f, stderr=f) + f.seek(0) + output = f.read() + if output: + self._logger.debug("Output was: '%s'", output) + self.play(fname) + os.remove(fname) + +class GoogleTTS(AbstractMp3TTSEngine): + """ + Uses the Google TTS online translator + Requires pymad and gTTS to be available + """ + + SLUG = "google-tts" + + def __init__(self, language='en'): + super(self.__class__, self).__init__() + self.language = language + + @classmethod + def is_available(cls): + return (super(cls, cls).is_available() and 'gtts' in sys.modules.keys()) + + @property + def languages(self): + langs = ['af', 'sq', 'ar', 'hy', 'ca', 'zh-CN', 'zh-TW', 'hr', 'cs', 'da', 'nl', 'en', 'eo', 'fi', 'fr', 'de', + 'el', 'ht', 'hi', 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv', 'mk', 'no', 'pl', 'pt', 'ro', 'ru', + 'sr', 'sk', 'es', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy'] + return langs + + def say(self, phrase): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) + if self.language not in self.languages: + raise ValueError("Language '%s' not supported by '%s'", self.language, self.SLUG) + tts = gtts.gTTS(text=phrase, lang=self.language) + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: + tmpfile = f.name + tts.save(tmpfile) + self.play_mp3(tmpfile) + os.remove(tmpfile) + +def get_default_engine_slug(): + return 'osx-tts' if platform.system() == 'darwin' else 'espeak-tts' + +def get_engine_by_slug(slug=None): + """ + Returns: + A speaker implementation available on the current platform + + Raises: + ValueError if no speaker implementation is supported on this platform + """ + + if not slug or type(slug) is not str: + raise TypeError("Invalid slug '%s'", slug) + + selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == slug, get_engines()) + if len(selected_engines) == 0: + raise ValueError("No TTS engine found for slug '%s'" % slug) + else: + if len(selected_engines) > 1: + print("WARNING: Multiple TTS engines found for slug '%s'. This is most certainly a bug." % slug) + engine = selected_engines[0] + if not engine.is_available(): + raise ValueError("TTS engine '%s' is not available (due to missing dependencies, missing dependencies, etc.)" % slug) + return engine + +def get_engines(): + def get_subclasses(cls): + subclasses = set() + for subclass in cls.__subclasses__(): + subclasses.add(subclass) + subclasses.update(get_subclasses(subclass)) + return subclasses + return [tts_engine for tts_engine in list(get_subclasses(AbstractTTSEngine)) if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG] + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Jasper TTS module') + parser.add_argument('--debug', action='store_true', help='Show debug messages') + args = parser.parse_args() + + logging.basicConfig() + if args.debug: + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + engines = get_engines() + available_engines = [] + for engine in get_engines(): + if engine.is_available(): + available_engines.append(engine) + print("Available TTS engines:") + for i, engine in enumerate(available_engines, start=1): + print("%d. %s" % (i, engine.SLUG)) + + print("") + print("Disabled TTS engines:") + for i, engine in enumerate(list(set(engines).difference(set(available_engines))), start=1): + print("%d. %s" % (i, engine.SLUG)) + + print("") + for i, engine in enumerate(available_engines, start=1): + print("%d. Testing engine '%s'..." % (i, engine.SLUG)) + engine().say("This is a test.") + print("Done.") |
