From 60d7bbf98230405242880a95af03768d4bb41afb Mon Sep 17 00:00:00 2001 From: schneefux Date: Sat, 9 Aug 2014 21:33:27 +0200 Subject: Cleanup client.speaker and add additional tts speakers (svox-pico-tts and google-tts) Also, get rid of aplay and use pyaudio instead (pyaudio is a dependency anyway, so why rely on an external tool that might not be installed) --- client/speaker.py | 181 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 159 insertions(+), 22 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index c20c01b..a0fb693 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -8,45 +8,182 @@ Speaker methods: isAvailable - returns True if the platform supports this implementation """ import os +import re +import sys import json +import tempfile +import subprocess +from abc import ABCMeta, abstractmethod + +import pyaudio +import wave +try: + import mad + import gtts +except ImportError: + pass + +class AbstractSpeaker(object): + """ + Generic parent class for all speakers + """ - -class eSpeakSpeaker: - + __metaclass__ = ABCMeta + + @classmethod + @abstractmethod + def isAvailable(cls): + pass + + @abstractmethod + def say(self, phrase, *args): + pass + + def play(self, filename, chunksize=1024): + f = wave.open(filename, 'rb') + p = pyaudio.PyAudio() + stream = p.open(format=p.get_format_from_width(f.getsampwidth()), + channels=f.getnchannels(), + rate=f.getframerate(), + output=True) + + data = f.readframes(chunksize) + while data: + stream.write(data) + data = f.readframes(chunksize) + + stream.stop_stream() + stream.close() + p.terminate() + +class AbstractMp3Speaker(AbstractSpeaker): + """ + Generic class that implements the 'play' method for mp3 files + """ + @classmethod + def isAvailable(cls): + return True if 'mad' in sys.modules.keys() else False + + def play_mp3(cls, filename): + f = mad.MadFile(filename) + p = pyaudio.PyAudio() + # open stream + stream = p.open(format=p.get_format_from_width(pyaudio.paInt32), + channels=2, + rate=f.samplerate(), + output=True) + + data = f.read() + while data: + stream.write(data) + data = f.read() + + stream.stop_stream() + stream.close() + p.terminate() + +class eSpeakSpeaker(AbstractSpeaker): """ Uses the eSpeak speech synthesizer included in the Jasper disk image + Requires espeak to be available """ @classmethod def isAvailable(cls): - return os.system("which espeak") == 0 - - def say(self, phrase, OPTIONS=" -vdefault+m3 -p 40 -s 160 --stdout > say.wav"): - os.system("espeak " + json.dumps(phrase, False, False) + OPTIONS) - self.play("say.wav") - - def play(self, filename): - os.system("aplay -D hw:1,0 " + filename) - - -class saySpeaker: - + return (super(cls, cls).isAvailable() and subprocess.call(['which','espeak']) == 0) + + def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + fname = f.name + cmd = ['espeak', '-v', voice, + '-p', pitch_adjustment, + '-s', words_per_minute, + '-w', fname] + cmd = [str(x) for x in cmd] + subprocess.call(cmd) + self.play(fname) + os.remove(fname) + +class saySpeaker(AbstractSpeaker): """ Uses the OS X built-in 'say' command """ @classmethod def isAvailable(cls): - return os.system("which say") == 0 - - def shellquote(self, s): - return "'" + s.replace("'", "'\\''") + "'" + return (subprocess.call(['which','say']) == 0) def say(self, phrase): - os.system("say " + self.shellquote(phrase)) + cmd = ['say', str(phrase)] + subprocess.call(cmd) def play(self, filename): - os.system("afplay " + filename) + cmd = ['afplay', str(filename)] + subprocess.call(cmd) +class picoSpeaker(AbstractSpeaker): + """ + Uses the svox-pico-tts speech synthesizer + Requires pico2wave to be available + """ + @classmethod + def isAvailable(cls): + return (super(cls, cls).isAvailable() and subprocess.call(['which','pico2wave']) == 0) + + @property + def languages(self): + cmd = ['pico2wave', '-l', 'NULL', + '-w', '/dev/null', + '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, language="en-US"): + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + fname = f.name + cmd = ['pico2wave', '--wave', fname] + if language: + if language not in self.languages: + raise ValueError("Language '%s' not supported by '%s'", language, cmd[0]) + cmd.extend(['-l',language]) + cmd.append(phrase) + + subprocess.call(cmd) + self.play(fname) + os.remove(fname) + +class googleSpeaker(AbstractMp3Speaker): + """ + Uses the Google TTS online translator + Requires pymad and gTTS to be available + """ + @classmethod + def isAvailable(cls): + return (super(cls, cls).isAvailable() 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, language='en'): + if language not in self.languages: + raise ValueError("Language '%s' not supported by '%s'", language, cmd[0]) + tts = gtts.gTTS(text=phrase, lang=language) + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: + tmpfile = f.name + tts.save(tmpfile) + self.play_mp3(tmpfile) + os.remove(tmpfile) def newSpeaker(): """ @@ -57,7 +194,7 @@ def newSpeaker(): ValueError if no speaker implementation is supported on this platform """ - for cls in [eSpeakSpeaker, saySpeaker]: + for cls in [googleSpeaker, picoSpeaker, eSpeakSpeaker, saySpeaker]: if cls.isAvailable(): return cls() raise ValueError("Platform is not supported") -- cgit v1.3.1 From d7319a92bd4a2b86b8180268087418dc8e5e51f4 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 10 Sep 2014 20:20:05 +0200 Subject: Rename isAvailable() to is_available() --- client/speaker.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index a0fb693..a63b04f 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -5,7 +5,7 @@ A Speaker handles audio output from Jasper to the user Speaker methods: say - output 'phrase' as speech play - play the audio in 'filename' - isAvailable - returns True if the platform supports this implementation + is_available - returns True if the platform supports this implementation """ import os import re @@ -32,7 +32,7 @@ class AbstractSpeaker(object): @classmethod @abstractmethod - def isAvailable(cls): + def is_available(cls): pass @abstractmethod @@ -61,7 +61,7 @@ class AbstractMp3Speaker(AbstractSpeaker): Generic class that implements the 'play' method for mp3 files """ @classmethod - def isAvailable(cls): + def is_available(cls): return True if 'mad' in sys.modules.keys() else False def play_mp3(cls, filename): @@ -88,8 +88,8 @@ class eSpeakSpeaker(AbstractSpeaker): Requires espeak to be available """ @classmethod - def isAvailable(cls): - return (super(cls, cls).isAvailable() and subprocess.call(['which','espeak']) == 0) + def is_available(cls): + return (super(cls, cls).is_available() and subprocess.call(['which','espeak']) == 0) def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: @@ -109,7 +109,7 @@ class saySpeaker(AbstractSpeaker): """ @classmethod - def isAvailable(cls): + def is_available(cls): return (subprocess.call(['which','say']) == 0) def say(self, phrase): @@ -126,8 +126,8 @@ class picoSpeaker(AbstractSpeaker): Requires pico2wave to be available """ @classmethod - def isAvailable(cls): - return (super(cls, cls).isAvailable() and subprocess.call(['which','pico2wave']) == 0) + def is_available(cls): + return (super(cls, cls).is_available() and subprocess.call(['which','pico2wave']) == 0) @property def languages(self): @@ -165,8 +165,8 @@ class googleSpeaker(AbstractMp3Speaker): Requires pymad and gTTS to be available """ @classmethod - def isAvailable(cls): - return (super(cls, cls).isAvailable() and 'gtts' in sys.modules.keys()) + def is_available(cls): + return (super(cls, cls).is_available() and 'gtts' in sys.modules.keys()) @property def languages(self): @@ -195,6 +195,6 @@ def newSpeaker(): """ for cls in [googleSpeaker, picoSpeaker, eSpeakSpeaker, saySpeaker]: - if cls.isAvailable(): + if cls.is_available(): return cls() raise ValueError("Platform is not supported") -- cgit v1.3.1 From 7cafe838582598a6881182e48dda360f74c0a312 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 10 Sep 2014 20:22:44 +0200 Subject: Added slugs to TTS engines --- client/speaker.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index a63b04f..027e973 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -87,6 +87,9 @@ class eSpeakSpeaker(AbstractSpeaker): Uses the eSpeak speech synthesizer included in the Jasper disk image Requires espeak to be available """ + + SLUG = "espeak-tts" + @classmethod def is_available(cls): return (super(cls, cls).is_available() and subprocess.call(['which','espeak']) == 0) @@ -108,6 +111,8 @@ class saySpeaker(AbstractSpeaker): Uses the OS X built-in 'say' command """ + SLUG = "osx-tts" + @classmethod def is_available(cls): return (subprocess.call(['which','say']) == 0) @@ -125,6 +130,9 @@ class picoSpeaker(AbstractSpeaker): Uses the svox-pico-tts speech synthesizer Requires pico2wave to be available """ + + SLUG = "pico-tts" + @classmethod def is_available(cls): return (super(cls, cls).is_available() and subprocess.call(['which','pico2wave']) == 0) @@ -164,6 +172,9 @@ class googleSpeaker(AbstractMp3Speaker): Uses the Google TTS online translator Requires pymad and gTTS to be available """ + + SLUG = "google-tts" + @classmethod def is_available(cls): return (super(cls, cls).is_available() and 'gtts' in sys.modules.keys()) -- cgit v1.3.1 From 78364574c3ed067cf7f607c28b2965010dbbcbd7 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 10 Sep 2014 20:26:43 +0200 Subject: Improve is_available method --- client/speaker.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 027e973..2a296c8 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -33,7 +33,7 @@ class AbstractSpeaker(object): @classmethod @abstractmethod def is_available(cls): - pass + return True @abstractmethod def say(self, phrase, *args): @@ -62,7 +62,7 @@ class AbstractMp3Speaker(AbstractSpeaker): """ @classmethod def is_available(cls): - return True if 'mad' in sys.modules.keys() else False + return (super(AbstractMp3Speaker, cls).is_available() and 'mad' in sys.modules.keys()) def play_mp3(cls, filename): f = mad.MadFile(filename) @@ -92,7 +92,7 @@ class eSpeakSpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (super(cls, cls).is_available() and subprocess.call(['which','espeak']) == 0) + return (super(eSpeakSpeaker, cls).is_available() and subprocess.call(['which','espeak']) == 0) def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: @@ -115,7 +115,7 @@ class saySpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (subprocess.call(['which','say']) == 0) + return (super(saySpeaker, cls).is_available() and subprocess.call(['which','say']) == 0) def say(self, phrase): cmd = ['say', str(phrase)] @@ -135,7 +135,7 @@ class picoSpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (super(cls, cls).is_available() and subprocess.call(['which','pico2wave']) == 0) + return (super(picoSpeaker, cls).is_available() and subprocess.call(['which','pico2wave']) == 0) @property def languages(self): @@ -177,7 +177,7 @@ class googleSpeaker(AbstractMp3Speaker): @classmethod def is_available(cls): - return (super(cls, cls).is_available() and 'gtts' in sys.modules.keys()) + return (super(googleSpeaker, cls).is_available() and 'gtts' in sys.modules.keys()) @property def languages(self): @@ -209,3 +209,4 @@ def newSpeaker(): if cls.is_available(): return cls() raise ValueError("Platform is not supported") + \ No newline at end of file -- cgit v1.3.1 From f5126416f47956e792c8adff2d1631390d435e3b Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 10 Sep 2014 20:42:29 +0200 Subject: Actually include a phrase in eSpeakSpeaker.say() --- client/speaker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 2a296c8..41b4f78 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -100,7 +100,8 @@ class eSpeakSpeaker(AbstractSpeaker): cmd = ['espeak', '-v', voice, '-p', pitch_adjustment, '-s', words_per_minute, - '-w', fname] + '-w', fname, + phrase] cmd = [str(x) for x in cmd] subprocess.call(cmd) self.play(fname) -- cgit v1.3.1 From f099b6eda8a48236fc801fb1e7a0714f7c665eaa Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 10 Sep 2014 20:48:04 +0200 Subject: Added logic to select the TTS engine from a slug --- client/speaker.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 41b4f78..c69ff95 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -197,6 +197,8 @@ class googleSpeaker(AbstractMp3Speaker): self.play_mp3(tmpfile) os.remove(tmpfile) +TTS_ENGINES = [googleSpeaker, picoSpeaker, eSpeakSpeaker, saySpeaker] + def newSpeaker(): """ Returns: @@ -206,8 +208,15 @@ def newSpeaker(): ValueError if no speaker implementation is supported on this platform """ - for cls in [googleSpeaker, picoSpeaker, eSpeakSpeaker, saySpeaker]: - if cls.is_available(): - return cls() - raise ValueError("Platform is not supported") - \ No newline at end of file + tts_engine = 'espeak-tts' + + selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == tts_engine, TTS_ENGINES) + if len(selected_engines) == 0: + raise ValueError("No TTS engine found for slug '%s'" % tts_engine) + else: + if len(selected_engines) > 1: + print("WARNING: Multiple TTS engines found for slug '%s'. This is most certainly a bug." % tts_engine) + engine = selected_engines[0] + if not engine.is_available(): + raise ValueError("TTS engine '%s' is not available (due to missing dependencies, missing dependencies, etc.)" % tts_engine) + return engine() -- cgit v1.3.1 From 0edd5741d36c0f84ce5bbba3288793081909f5a9 Mon Sep 17 00:00:00 2001 From: schneefux Date: Thu, 11 Sep 2014 14:27:40 +0200 Subject: Getting tts engine from profile --- client/speaker.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index c69ff95..64bbc7b 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -208,8 +208,22 @@ def newSpeaker(): ValueError if no speaker implementation is supported on this platform """ - tts_engine = 'espeak-tts' - + tts_engine = None + + # Try to get tts_engine from config + if os.path.exists('profile.yml'): + with open('profile.yml', 'r') as f: + profile = yaml.safe_load(f) + if 'tts_engine' in profile: + tts_engine = profile['tts_engine'] + + # Default values if config file does not exist or option not set + if not tts_engine: + if platform.system() == 'darwin': + tts_engine = 'osx-tts' + else: + tts_engine = 'espeak-tts' + selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == tts_engine, TTS_ENGINES) if len(selected_engines) == 0: raise ValueError("No TTS engine found for slug '%s'" % tts_engine) -- cgit v1.3.1 From 572394fa736bd557330c6e2f513633bb34851376 Mon Sep 17 00:00:00 2001 From: schneefux Date: Thu, 11 Sep 2014 14:41:09 +0200 Subject: Added missing import --- client/speaker.py | 1 + 1 file changed, 1 insertion(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 64bbc7b..b43e0b5 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -8,6 +8,7 @@ Speaker methods: is_available - returns True if the platform supports this implementation """ import os +import platform import re import sys import json -- cgit v1.3.1 From b95293f9b453924e54a8c216951fe2a27378e369 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 09:59:57 +0200 Subject: Remove unused import --- client/speaker.py | 1 - 1 file changed, 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index b43e0b5..d168cbf 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -11,7 +11,6 @@ import os import platform import re import sys -import json import tempfile import subprocess from abc import ABCMeta, abstractmethod -- cgit v1.3.1 From ac160d2c64f1060700adc74f41af431298f77292 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 10:00:43 +0200 Subject: Add yaml import to speaker.py --- client/speaker.py | 1 + 1 file changed, 1 insertion(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index d168cbf..0ce3562 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -15,6 +15,7 @@ import tempfile import subprocess from abc import ABCMeta, abstractmethod +import yaml import pyaudio import wave try: -- cgit v1.3.1 From dc64f15eeb5fc0db1fa8b8a36abeca1eeaab0f60 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 10:02:56 +0200 Subject: Use slug in Exception --- client/speaker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 0ce3562..9dab72a 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -161,7 +161,7 @@ class picoSpeaker(AbstractSpeaker): cmd = ['pico2wave', '--wave', fname] if language: if language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", language, cmd[0]) + raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) cmd.extend(['-l',language]) cmd.append(phrase) @@ -190,7 +190,7 @@ class googleSpeaker(AbstractMp3Speaker): def say(self, phrase, language='en'): if language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", language, cmd[0]) + raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) tts = gtts.gTTS(text=phrase, lang=language) with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: tmpfile = f.name -- cgit v1.3.1 From c98a33ea2457984f54901cd61812b4cd308e65fc Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 10:17:51 +0200 Subject: Added __main__ testing code to client/speaker.py --- client/speaker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 9dab72a..ddf5e54 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -235,3 +235,15 @@ def newSpeaker(): if not engine.is_available(): raise ValueError("TTS engine '%s' is not available (due to missing dependencies, missing dependencies, etc.)" % tts_engine) return engine() + +if __name__ == '__main__': + engines = [] + for engine in AbstractSpeaker.__subclasses__(): + if hasattr(engine, 'SLUG'): + instance = engine() + if instance.is_available: + engines.append(instance) + + for engine in engines: + print engine.SLUG + engine.say("This is a test.") -- cgit v1.3.1 From 8c3c9206ccd52ba75699e0ef333a1a7a14815501 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 10:31:18 +0200 Subject: Simplify is_available() method of osx-speaker --- client/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index ddf5e54..24041e2 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -117,7 +117,7 @@ class saySpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (super(saySpeaker, cls).is_available() and subprocess.call(['which','say']) == 0) + return (platform.system() == 'darwin') def say(self, phrase): cmd = ['say', str(phrase)] -- cgit v1.3.1 From 8a396c3cc0b94adfb448bcc407011481adcb2b20 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 15 Sep 2014 10:31:50 +0200 Subject: Fix missing brackets in speaker.py testing code --- client/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 24041e2..df63eee 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -241,7 +241,7 @@ if __name__ == '__main__': for engine in AbstractSpeaker.__subclasses__(): if hasattr(engine, 'SLUG'): instance = engine() - if instance.is_available: + if instance.is_available(): engines.append(instance) for engine in engines: -- cgit v1.3.1 From d71c77460f8eb2564189b4695f4d1187beb8a8b2 Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 15:17:44 +0200 Subject: Change AbstractSpeaker.play() to use aplay due to issues with PyAudio playback see jasperproject/jasper-client#188 for more info --- client/speaker.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index df63eee..54a039f 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -40,22 +40,11 @@ class AbstractSpeaker(object): def say(self, phrase, *args): pass - def play(self, filename, chunksize=1024): - f = wave.open(filename, 'rb') - p = pyaudio.PyAudio() - stream = p.open(format=p.get_format_from_width(f.getsampwidth()), - channels=f.getnchannels(), - rate=f.getframerate(), - output=True) - - data = f.readframes(chunksize) - while data: - stream.write(data) - data = f.readframes(chunksize) - - stream.stop_stream() - stream.close() - p.terminate() + def play(self, filename): + # FIXME: Use platform-independent audio-output here + # See issue jasperproject/jasper-client#188 + cmd = ['aplay', str(filename)] + subprocess.call(cmd) class AbstractMp3Speaker(AbstractSpeaker): """ -- cgit v1.3.1 From 3970fabe28e60d5e944de9cdeffe5ff6ab0e55ea Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 16:22:27 +0200 Subject: Change AbstractMP3Speaker.play_mp3() to use AbstractSpeaker.play() --- client/speaker.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 54a039f..d89313f 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -54,23 +54,19 @@ class AbstractMp3Speaker(AbstractSpeaker): def is_available(cls): return (super(AbstractMp3Speaker, cls).is_available() and 'mad' in sys.modules.keys()) - def play_mp3(cls, filename): - f = mad.MadFile(filename) - p = pyaudio.PyAudio() - # open stream - stream = p.open(format=p.get_format_from_width(pyaudio.paInt32), - channels=2, - rate=f.samplerate(), - output=True) - - data = f.read() - while data: - stream.write(data) - data = f.read() - - stream.stop_stream() - stream.close() - p.terminate() + 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 eSpeakSpeaker(AbstractSpeaker): """ -- cgit v1.3.1 From aee49f0a5d37e296699c9b8a3aef0cde0c31aa40 Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 16:24:00 +0200 Subject: Add better TTS engine detection/testing code --- client/speaker.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index d89313f..bcf3702 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -221,14 +221,32 @@ def newSpeaker(): raise ValueError("TTS engine '%s' is not available (due to missing dependencies, missing dependencies, etc.)" % tts_engine) 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(AbstractSpeaker)) if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG] + if __name__ == '__main__': - engines = [] - for engine in AbstractSpeaker.__subclasses__(): - if hasattr(engine, 'SLUG'): - instance = engine() - if instance.is_available(): - engines.append(instance) - - for engine in engines: - print engine.SLUG - engine.say("This is a test.") + 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.") -- cgit v1.3.1 From 4d9cefe521b6bec2722479c8640a7ed5725f1e84 Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 16:54:30 +0200 Subject: Use distutils.spawn.find_executable instead of which --- client/speaker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index bcf3702..7b9c018 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -14,6 +14,7 @@ import sys import tempfile import subprocess from abc import ABCMeta, abstractmethod +from distutils.spawn import find_executable import yaml import pyaudio @@ -78,7 +79,7 @@ class eSpeakSpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (super(eSpeakSpeaker, cls).is_available() and subprocess.call(['which','espeak']) == 0) + return (super(eSpeakSpeaker, cls).is_available() and find_executable('espeak') is not None) def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: @@ -102,7 +103,7 @@ class saySpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (platform.system() == 'darwin') + return (platform.system() == 'darwin' and find_executable('say') is not None and find_executable('afplay') is not None) def say(self, phrase): cmd = ['say', str(phrase)] @@ -122,7 +123,7 @@ class picoSpeaker(AbstractSpeaker): @classmethod def is_available(cls): - return (super(picoSpeaker, cls).is_available() and subprocess.call(['which','pico2wave']) == 0) + return (super(picoSpeaker, cls).is_available() and find_executable('pico2wave') is not None) @property def languages(self): -- cgit v1.3.1 From 653f1057153af51d88ceae517328df9f919ea9ce Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 16:55:44 +0200 Subject: Use os.devnull instead of hardcoded /dev/null --- client/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 7b9c018..7e90986 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -128,7 +128,7 @@ class picoSpeaker(AbstractSpeaker): @property def languages(self): cmd = ['pico2wave', '-l', 'NULL', - '-w', '/dev/null', + '-w', os.devnull, 'NULL'] with tempfile.SpooledTemporaryFile() as f: subprocess.call(cmd, stderr=f) -- cgit v1.3.1 From a94a3d6d17beb4e1e06d93d08a7c246615064624 Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 19 Sep 2014 19:26:49 +0200 Subject: Added logging system to client/speaker.py --- client/speaker.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 7 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 7e90986..5271459 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -13,10 +13,14 @@ 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: @@ -29,7 +33,6 @@ class AbstractSpeaker(object): """ Generic parent class for all speakers """ - __metaclass__ = ABCMeta @classmethod @@ -37,6 +40,9 @@ class AbstractSpeaker(object): def is_available(cls): return True + def __init__(self): + self._logger = logging.getLogger(__name__) + @abstractmethod def say(self, phrase, *args): pass @@ -45,7 +51,13 @@ class AbstractSpeaker(object): # FIXME: Use platform-independent audio-output here # See issue jasperproject/jasper-client#188 cmd = ['aplay', str(filename)] - subprocess.call(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) class AbstractMp3Speaker(AbstractSpeaker): """ @@ -82,6 +94,7 @@ class eSpeakSpeaker(AbstractSpeaker): return (super(eSpeakSpeaker, cls).is_available() and find_executable('espeak') is not None) def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): + 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', voice, @@ -90,7 +103,13 @@ class eSpeakSpeaker(AbstractSpeaker): '-w', fname, phrase] cmd = [str(x) for x in cmd] - subprocess.call(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) @@ -106,12 +125,25 @@ class saySpeaker(AbstractSpeaker): 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)] - subprocess.call(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) def play(self, filename): cmd = ['afplay', str(filename)] - subprocess.call(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) class picoSpeaker(AbstractSpeaker): """ @@ -142,6 +174,7 @@ class picoSpeaker(AbstractSpeaker): return langs def say(self, phrase, language="en-US"): + 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] @@ -150,8 +183,13 @@ class picoSpeaker(AbstractSpeaker): raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) cmd.extend(['-l',language]) cmd.append(phrase) - - subprocess.call(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) @@ -175,6 +213,7 @@ class googleSpeaker(AbstractMp3Speaker): return langs def say(self, phrase, language='en'): + self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) if language not in self.languages: raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) tts = gtts.gTTS(text=phrase, lang=language) @@ -232,6 +271,15 @@ def get_engines(): return [tts_engine for tts_engine in list(get_subclasses(AbstractSpeaker)) 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(): -- cgit v1.3.1 From 4c08bf1eb9697466603e699d22370e729cb43d53 Mon Sep 17 00:00:00 2001 From: schneefux Date: Mon, 22 Sep 2014 19:01:31 +0200 Subject: Added festival TTS engine --- client/speaker.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 5271459..d41d6ae 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -113,6 +113,46 @@ class eSpeakSpeaker(AbstractSpeaker): self.play(fname) os.remove(fname) +class festivalSpeaker(AbstractSpeaker): + """ + Uses the festival speech synthesizer + Requires festival (text2wave) to be available + """ + + SLUG = 'festival-tts' + + @classmethod + def is_available(cls): + if super(festivalSpeaker, 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 saySpeaker(AbstractSpeaker): """ Uses the OS X built-in 'say' command -- cgit v1.3.1 From dad743260c758761dd7d27023a3cd4e14857e2bc Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 12:02:01 +0200 Subject: Added dummy tts engine (for testing purposes) --- client/speaker.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index d41d6ae..ea8355c 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -81,6 +81,25 @@ class AbstractMp3Speaker(AbstractSpeaker): wav.close() self.play(f.name) +class DummySpeaker(AbstractSpeaker): + """ + 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 eSpeakSpeaker(AbstractSpeaker): """ Uses the eSpeak speech synthesizer included in the Jasper disk image -- cgit v1.3.1 From 3f5e2e98b328ba468315f0e0a69463ba37a23ecd Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 12:06:06 +0200 Subject: Check for `aplay` in AbstractSpeaker (because of af31dc5) --- client/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index ea8355c..8cedaa2 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -38,7 +38,7 @@ class AbstractSpeaker(object): @classmethod @abstractmethod def is_available(cls): - return True + return (find_executable('aplay') is not None) def __init__(self): self._logger = logging.getLogger(__name__) -- cgit v1.3.1 From 4fc9567ff2c326c348e559c681679363b01daf59 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 15:28:06 +0200 Subject: Remove TTS_ENGINES constant from `client/speaker.py` --- client/speaker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 8cedaa2..96fb3ed 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -282,8 +282,6 @@ class googleSpeaker(AbstractMp3Speaker): self.play_mp3(tmpfile) os.remove(tmpfile) -TTS_ENGINES = [googleSpeaker, picoSpeaker, eSpeakSpeaker, saySpeaker] - def newSpeaker(): """ Returns: @@ -309,7 +307,7 @@ def newSpeaker(): else: tts_engine = 'espeak-tts' - selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == tts_engine, TTS_ENGINES) + selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == tts_engine, get_engines()) if len(selected_engines) == 0: raise ValueError("No TTS engine found for slug '%s'" % tts_engine) else: -- cgit v1.3.1 From 8c71b2cc05500fc72a6bc8a925f4411f7c52c903 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 16:05:56 +0200 Subject: Move tts_engine_slug to jasper.py, improve module functions of speaker.py --- client/speaker.py | 34 ++++++++++++---------------------- jasper.py | 9 ++++++++- 2 files changed, 20 insertions(+), 23 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index 96fb3ed..cdebe2c 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -282,7 +282,10 @@ class googleSpeaker(AbstractMp3Speaker): self.play_mp3(tmpfile) os.remove(tmpfile) -def newSpeaker(): +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 @@ -290,33 +293,20 @@ def newSpeaker(): Raises: ValueError if no speaker implementation is supported on this platform """ - - tts_engine = None - - # Try to get tts_engine from config - if os.path.exists('profile.yml'): - with open('profile.yml', 'r') as f: - profile = yaml.safe_load(f) - if 'tts_engine' in profile: - tts_engine = profile['tts_engine'] - # Default values if config file does not exist or option not set - if not tts_engine: - if platform.system() == 'darwin': - tts_engine = 'osx-tts' - else: - tts_engine = 'espeak-tts' - - selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == tts_engine, get_engines()) + 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'" % tts_engine) + 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." % tts_engine) + 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.)" % tts_engine) - return engine() + 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): diff --git a/jasper.py b/jasper.py index 62713ee..1846b8e 100755 --- a/jasper.py +++ b/jasper.py @@ -53,12 +53,19 @@ class Jasper(object): stt_engine_type = "sphinx" logger.warning("stt_engine not specified in profile, defaulting to '%s'", stt_engine_type) + try: + tts_engine_slug = self.config['tts_engine'] + except KeyError: + tts_engine_slug = speak.get_default_engine_slug() + logger.warning("tts_engine not specified in profile, defaulting to '%s'", tts_engine_slug) + tts_engine = speak.get_engine_by_slug(tts_engine_slug) + # Compile dictionary sentences, dictionary, languagemodel = [os.path.abspath(os.path.join(jasperpath.LIB_PATH, filename)) for filename in ("sentences.txt", "dictionary.dic", "languagemodel.lm")] vocabcompiler.compile(sentences, dictionary, languagemodel) # Initialize Mic - self.mic = Mic(speak.newSpeaker(), stt.PocketSphinxSTT(), stt.newSTTEngine(stt_engine_type, api_key=api_key)) + self.mic = Mic(tts_engine(), stt.PocketSphinxSTT(), stt.newSTTEngine(stt_engine_type, api_key=api_key)) def run(self): if 'first_name' in self.config: -- cgit v1.3.1 From ee27d799fe83bdcf88093649f92c7127d4236cf5 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 16:25:50 +0200 Subject: Move tts engine options from say method to __init__ --- client/speaker.py | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py index cdebe2c..e3d3875 100644 --- a/client/speaker.py +++ b/client/speaker.py @@ -40,7 +40,7 @@ class AbstractSpeaker(object): def is_available(cls): return (find_executable('aplay') is not None) - def __init__(self): + def __init__(self, **kwargs): self._logger = logging.getLogger(__name__) @abstractmethod @@ -108,17 +108,23 @@ class eSpeakSpeaker(AbstractSpeaker): 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(eSpeakSpeaker, cls).is_available() and find_executable('espeak') is not None) - def say(self, phrase, voice='default+m3', pitch_adjustment=40, words_per_minute=160): + 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', voice, - '-p', pitch_adjustment, - '-s', words_per_minute, + cmd = ['espeak', '-v', self.voice, + '-p', self.pitch_adjustment, + '-s', self.words_per_minute, '-w', fname, phrase] cmd = [str(x) for x in cmd] @@ -212,6 +218,10 @@ class picoSpeaker(AbstractSpeaker): SLUG = "pico-tts" + def __init__(self, language="en-US"): + super(self.__class__, self).__init__() + self.language = language + @classmethod def is_available(cls): return (super(picoSpeaker, cls).is_available() and find_executable('pico2wave') is not None) @@ -232,15 +242,14 @@ class picoSpeaker(AbstractSpeaker): langs = matchobj.group(1).split() return langs - def say(self, phrase, language="en-US"): + 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 language: - if language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) - cmd.extend(['-l',language]) + 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: @@ -260,6 +269,10 @@ class googleSpeaker(AbstractMp3Speaker): SLUG = "google-tts" + def __init__(self, language='en'): + super(self.__class__, self).__init__() + self.language = language + @classmethod def is_available(cls): return (super(googleSpeaker, cls).is_available() and 'gtts' in sys.modules.keys()) @@ -271,11 +284,11 @@ class googleSpeaker(AbstractMp3Speaker): 'sr', 'sk', 'es', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy'] return langs - def say(self, phrase, language='en'): + def say(self, phrase): self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - if language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", language, self.SLUG) - tts = gtts.gTTS(text=phrase, lang=language) + 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) -- cgit v1.3.1 From 6e55abfb09c611abf961cbd4c53e286b39ff6218 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 16:59:48 +0200 Subject: Rename `speaker.py` to `tts.py` to match `stt.py` and also change class names --- client/speaker.py | 361 ------------------------------------------------------ client/tts.py | 361 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ jasper.py | 9 +- 3 files changed, 365 insertions(+), 366 deletions(-) delete mode 100644 client/speaker.py create mode 100644 client/tts.py (limited to 'client') diff --git a/client/speaker.py b/client/speaker.py deleted file mode 100644 index e3d3875..0000000 --- a/client/speaker.py +++ /dev/null @@ -1,361 +0,0 @@ -# -*- 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 AbstractSpeaker(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 AbstractMp3Speaker(AbstractSpeaker): - """ - Generic class that implements the 'play' method for mp3 files - """ - @classmethod - def is_available(cls): - return (super(AbstractMp3Speaker, 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 DummySpeaker(AbstractSpeaker): - """ - 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 eSpeakSpeaker(AbstractSpeaker): - """ - 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(eSpeakSpeaker, 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 festivalSpeaker(AbstractSpeaker): - """ - Uses the festival speech synthesizer - Requires festival (text2wave) to be available - """ - - SLUG = 'festival-tts' - - @classmethod - def is_available(cls): - if super(festivalSpeaker, 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 saySpeaker(AbstractSpeaker): - """ - 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 picoSpeaker(AbstractSpeaker): - """ - 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(picoSpeaker, 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 googleSpeaker(AbstractMp3Speaker): - """ - 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(googleSpeaker, 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(AbstractSpeaker)) 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.") 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.") diff --git a/jasper.py b/jasper.py index 1846b8e..ece9e20 100755 --- a/jasper.py +++ b/jasper.py @@ -11,8 +11,7 @@ import yaml import argparse from client.diagnose import Diagnostics -from client import vocabcompiler, stt, jasperpath -from client import speaker as speak +from client import vocabcompiler, tts, stt, jasperpath from client.conversation import Conversation parser = argparse.ArgumentParser(description='Jasper Voice Control Center') @@ -56,16 +55,16 @@ class Jasper(object): try: tts_engine_slug = self.config['tts_engine'] except KeyError: - tts_engine_slug = speak.get_default_engine_slug() + tts_engine_slug = tts.get_default_engine_slug() logger.warning("tts_engine not specified in profile, defaulting to '%s'", tts_engine_slug) - tts_engine = speak.get_engine_by_slug(tts_engine_slug) + tts_engine_class = tts.get_engine_by_slug(tts_engine_slug) # Compile dictionary sentences, dictionary, languagemodel = [os.path.abspath(os.path.join(jasperpath.LIB_PATH, filename)) for filename in ("sentences.txt", "dictionary.dic", "languagemodel.lm")] vocabcompiler.compile(sentences, dictionary, languagemodel) # Initialize Mic - self.mic = Mic(tts_engine(), stt.PocketSphinxSTT(), stt.newSTTEngine(stt_engine_type, api_key=api_key)) + self.mic = Mic(tts_engine_class(), stt.PocketSphinxSTT(), stt.newSTTEngine(stt_engine_type, api_key=api_key)) def run(self): if 'first_name' in self.config: -- cgit v1.3.1 From a93846a2b75720741f71835269834a093e099955 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 17:00:12 +0200 Subject: Reuse speaker from mic in musicmode --- client/musicmode.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'client') diff --git a/client/musicmode.py b/client/musicmode.py index 334f94c..900c4f1 100644 --- a/client/musicmode.py +++ b/client/musicmode.py @@ -7,7 +7,6 @@ import os from mic import Mic import g2p from music import * -import speaker import stt @@ -42,7 +41,7 @@ class MusicMode: # create a new mic with the new music models self.mic = Mic( - speaker.newSpeaker(), + mic.speaker, stt.PocketSphinxSTT(lmd_music="languagemodel_spotify.lm", dictd_music="dictionary_spotify.dic"), stt.PocketSphinxSTT(lmd_music="languagemodel_spotify.lm", dictd_music="dictionary_spotify.dic") ) -- cgit v1.3.1 From 39e4be4ed9a619595417140469cd312575c0a37c Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 17:08:31 +0200 Subject: Added testcase for tts with DummyTTS engine --- client/test.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'client') diff --git a/client/test.py b/client/test.py index 0ad37a3..ffeff73 100644 --- a/client/test.py +++ b/client/test.py @@ -12,6 +12,7 @@ import vocabcompiler import g2p import brain import jasperpath +import tts from diagnose import Diagnostics DEFAULT_PROFILE = { @@ -192,6 +193,11 @@ class TestModules(unittest.TestCase): "can't see that far ahead" in outputs[0] or "Tomorrow" in outputs[0]) +class TestTTS(unittest.TestCase): + def testTTS(self): + tts_engine = tts.get_engine_by_slug('dummy-tts') + tts_instance = tts_engine() + tts_instance.say('This is a test.') class TestBrain(unittest.TestCase): @@ -239,7 +245,7 @@ if __name__ == '__main__': # Change CWD to jasperpath.LIB_PATH os.chdir(jasperpath.LIB_PATH) - test_cases = [TestBrain, TestModules, TestVocabCompiler] + test_cases = [TestBrain, TestModules, TestVocabCompiler, TestTTS] if not args.light: test_cases.append(TestG2P) test_cases.append(TestMic) -- cgit v1.3.1 From 982f650de5b8f58255c31e8c5e8d3abeb505ed32 Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 18:32:16 +0200 Subject: Get rid of pyaudio in `tts.py` --- client/tts.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'client') diff --git a/client/tts.py b/client/tts.py index 4bda53d..c540123 100644 --- a/client/tts.py +++ b/client/tts.py @@ -21,7 +21,6 @@ from distutils.spawn import find_executable import yaml import argparse -import pyaudio import wave try: import mad @@ -73,7 +72,7 @@ class AbstractMp3TTSEngine(AbstractTTSEngine): 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)) + wav.setsampwidth(4L) # width of 32 bit audio frame = mf.read() while frame is not None: wav.writeframes(frame) -- cgit v1.3.1 From c60e9a106f347af2327b4712fcf08689faeac53d Mon Sep 17 00:00:00 2001 From: schneefux Date: Wed, 24 Sep 2014 18:36:12 +0200 Subject: Delete tempfiles in play_mp3() method of AbstractMp3TTSEngine --- client/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/tts.py b/client/tts.py index c540123..df4d425 100644 --- a/client/tts.py +++ b/client/tts.py @@ -68,7 +68,7 @@ class AbstractMp3TTSEngine(AbstractTTSEngine): def play_mp3(self, filename): mf = mad.MadFile(filename) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix='.wav') as f: wav = wave.open(f, mode='wb') wav.setframerate(mf.samplerate()) wav.setnchannels(1 if mf.mode() == mad.MODE_SINGLE_CHANNEL else 2) -- cgit v1.3.1 From 434e94f0f3bfc0e57b4e2120f6fb5522dd0ed229 Mon Sep 17 00:00:00 2001 From: schneefux Date: Fri, 26 Sep 2014 19:10:46 +0200 Subject: Readd hardcoded alsa playback device (should be removed later) --- client/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'client') diff --git a/client/tts.py b/client/tts.py index df4d425..8fef776 100644 --- a/client/tts.py +++ b/client/tts.py @@ -49,7 +49,7 @@ class AbstractTTSEngine(object): def play(self, filename): # FIXME: Use platform-independent audio-output here # See issue jasperproject/jasper-client#188 - cmd = ['aplay', str(filename)] + cmd = ['aplay', '-D', 'hw:1,0', 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) -- cgit v1.3.1