diff options
Diffstat (limited to 'client')
| -rw-r--r-- | client/app_utils.py (renamed from client/modules/app_utils.py) | 0 | ||||
| -rw-r--r-- | client/brain.py | 6 | ||||
| -rw-r--r-- | client/local_mic.py | 3 | ||||
| -rw-r--r-- | client/mic.py | 58 | ||||
| -rw-r--r-- | client/musicmode.py | 3 | ||||
| -rw-r--r-- | client/speaker.py | 63 | ||||
| -rw-r--r-- | client/stt.py | 196 | ||||
| -rw-r--r-- | client/test.py | 21 | ||||
| -rw-r--r-- | client/test_mic.py | 3 | ||||
| -rw-r--r-- | client/tts.py | 360 |
10 files changed, 546 insertions, 167 deletions
diff --git a/client/modules/app_utils.py b/client/app_utils.py index cea6881..cea6881 100644 --- a/client/modules/app_utils.py +++ b/client/app_utils.py diff --git a/client/brain.py b/client/brain.py index b4620c3..d905853 100644 --- a/client/brain.py +++ b/client/brain.py @@ -35,11 +35,11 @@ class Brain(object): logger = logging.getLogger(__name__) module_locations = [jasperpath.PLUGIN_PATH] logger.debug("Looking for modules in: %s", ', '.join(["'%s'" % location for location in module_locations])) - module_names = [name for loader, name, ispkg in pkgutil.walk_packages(module_locations, prefix='modules.')] modules = [] - for name in module_names: + for finder, name, ispkg in pkgutil.walk_packages(module_locations): try: - mod = importlib.import_module(name) + loader = finder.find_module(name) + mod = loader.load_module(name) except: logger.warning("Skipped module '%s' due to an error.", name, exc_info=True) else: diff --git a/client/local_mic.py b/client/local_mic.py index 3da6ed1..3707439 100644 --- a/client/local_mic.py +++ b/client/local_mic.py @@ -15,6 +15,9 @@ class Mic: def passiveListen(self, PERSONA): return True, "JASPER" + def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, MUSIC=False): + return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, MUSIC=MUSIC)] + def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): if not LISTEN: return self.prev diff --git a/client/mic.py b/client/mic.py index 0351592..7956b1d 100644 --- a/client/mic.py +++ b/client/mic.py @@ -4,11 +4,13 @@ """ import os -from wave import open as open_audio +import tempfile +import wave import audioop import pyaudio import alteration import jasperpath +from stt import TranscriptionMode class Mic: @@ -68,6 +70,10 @@ class Mic: lastN.append(self.getScore(data)) average = sum(lastN) / len(lastN) + stream.stop_stream() + stream.close() + audio.terminate() + # this will be the benchmark to cause a disturbance over! THRESHOLD = average * THRESHOLD_MULTIPLIER @@ -80,7 +86,6 @@ class Mic: """ THRESHOLD_MULTIPLIER = 1.8 - AUDIO_FILE = "passive.wav" RATE = 16000 CHUNK = 1024 @@ -138,6 +143,9 @@ class Mic: # no use continuing if no flag raised if not didDetect: print "No disturbance detected" + stream.stop_stream() + stream.close() + audio.terminate() return (None, None) # cutoff any recording before this disturbance was detected @@ -154,15 +162,17 @@ class Mic: stream.stop_stream() stream.close() audio.terminate() - write_frames = open_audio(AUDIO_FILE, 'wb') - write_frames.setnchannels(1) - write_frames.setsampwidth(audio.get_sample_size(pyaudio.paInt16)) - write_frames.setframerate(RATE) - write_frames.writeframes(''.join(frames)) - write_frames.close() - - # check if PERSONA was said - transcribed = self.passive_stt_engine.transcribe(AUDIO_FILE, PERSONA_ONLY=True) + + with tempfile.NamedTemporaryFile(mode='w+b') as f: + wav_fp = wave.open(f, 'wb') + wav_fp.setnchannels(1) + wav_fp.setsampwidth(audio.get_sample_size(pyaudio.paInt16)) + wav_fp.setframerate(RATE) + wav_fp.writeframes(''.join(frames)) + wav_fp.close() + f.seek(0) + # check if PERSONA was said + transcribed = self.passive_stt_engine.transcribe(f, mode=TranscriptionMode.KEYWORD) if PERSONA in transcribed: return (THRESHOLD, PERSONA) @@ -187,18 +197,10 @@ class Mic: Returns a list of the matching options or None """ - AUDIO_FILE = "active.wav" RATE = 16000 CHUNK = 1024 LISTEN_TIME = 12 - # user can request pre-recorded sound - if not LISTEN: - if not os.path.exists(AUDIO_FILE): - return None - - return self.active_stt_engine.transcribe(AUDIO_FILE) - # check if no threshold provided if THRESHOLD == None: THRESHOLD = self.fetchThreshold() @@ -238,14 +240,18 @@ class Mic: stream.stop_stream() stream.close() audio.terminate() - write_frames = open_audio(AUDIO_FILE, 'wb') - write_frames.setnchannels(1) - write_frames.setsampwidth(audio.get_sample_size(pyaudio.paInt16)) - write_frames.setframerate(RATE) - write_frames.writeframes(''.join(frames)) - write_frames.close() - return self.active_stt_engine.transcribe(AUDIO_FILE, MUSIC=MUSIC) + with tempfile.SpooledTemporaryFile(mode='w+b') as f: + wav_fp = wave.open(f, 'wb') + wav_fp.setnchannels(1) + wav_fp.setsampwidth(audio.get_sample_size(pyaudio.paInt16)) + wav_fp.setframerate(RATE) + wav_fp.writeframes(''.join(frames)) + wav_fp.close() + f.seek(0) + mode = TranscriptionMode.MUSIC if MUSIC else TranscriptionMode.NORMAL + transcribed = self.active_stt_engine.transcribe(f, mode=mode) + return transcribed def say(self, phrase, OPTIONS=" -vdefault+m3 -p 40 -s 160 --stdout > say.wav"): # alter phrase before speaking 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") ) diff --git a/client/speaker.py b/client/speaker.py deleted file mode 100644 index c20c01b..0000000 --- a/client/speaker.py +++ /dev/null @@ -1,63 +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' - isAvailable - returns True if the platform supports this implementation -""" -import os -import json - - -class eSpeakSpeaker: - - """ - Uses the eSpeak speech synthesizer included in the Jasper disk image - """ - @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: - - """ - 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("'", "'\\''") + "'" - - def say(self, phrase): - os.system("say " + self.shellquote(phrase)) - - def play(self, filename): - os.system("afplay " + filename) - - -def newSpeaker(): - """ - Returns: - A speaker implementation available on the current platform - - Raises: - ValueError if no speaker implementation is supported on this platform - """ - - for cls in [eSpeakSpeaker, saySpeaker]: - if cls.isAvailable(): - return cls() - raise ValueError("Platform is not supported") diff --git a/client/stt.py b/client/stt.py index fc1bd1f..b4cc038 100644 --- a/client/stt.py +++ b/client/stt.py @@ -2,9 +2,12 @@ # -*- coding: utf-8-*- import os import traceback +import wave import json import tempfile +import pkgutil import logging +from abc import ABCMeta, abstractmethod import requests import yaml @@ -12,12 +15,37 @@ import yaml The default Speech-to-Text implementation which relies on PocketSphinx. """ +class TranscriptionMode: + NORMAL, KEYWORD, MUSIC = range(3) -class PocketSphinxSTT(object): +class AbstractSTTEngine(object): + """ + Generic parent class for all STT engines + """ + + __metaclass__ = ABCMeta + + @classmethod + def get_config(cls): + return {} + + @classmethod + @abstractmethod + def is_available(cls): + return True + + @abstractmethod + def transcribe(self, fp, mode=TranscriptionMode.NORMAL): + pass + +class PocketSphinxSTT(AbstractSTTEngine): + + SLUG = 'sphinx' def __init__(self, lmd="languagemodel.lm", dictd="dictionary.dic", lmd_persona="languagemodel_persona.lm", dictd_persona="dictionary_persona.dic", - lmd_music=None, dictd_music=None, **kwargs): + lmd_music=None, dictd_music=None, + hmm_dir="/usr/local/share/pocketsphinx/model/hmm/en_US/hub4wsj_sc_8k"): """ Initiates the pocketsphinx instance. @@ -37,38 +65,51 @@ class PocketSphinxSTT(object): except: import pocketsphinx as ps - hmm_dir = None + self._logfiles = {} + with tempfile.NamedTemporaryFile(prefix='psdecoder_music_', suffix='.log', delete=False) as f: + self._logfiles[TranscriptionMode.MUSIC] = f.name + with tempfile.NamedTemporaryFile(prefix='psdecoder_keyword_', suffix='.log', delete=False) as f: + self._logfiles[TranscriptionMode.KEYWORD] = f.name + with tempfile.NamedTemporaryFile(prefix='psdecoder_normal_', suffix='.log', delete=False) as f: + self._logfiles[TranscriptionMode.NORMAL] = f.name + + self._decoders = {} + if lmd_music and dictd_music: + self._decoders[TranscriptionMode.MUSIC] = ps.Decoder(hmm=hmm_dir, lm=lmd_music, dict=dictd_music, logfn=self._logfiles[TranscriptionMode.MUSIC]) + self._decoders[TranscriptionMode.KEYWORD] = ps.Decoder(hmm=hmm_dir, lm=lmd_persona, dict=dictd_persona, logfn=self._logfiles[TranscriptionMode.KEYWORD]) + self._decoders[TranscriptionMode.NORMAL] = ps.Decoder(hmm=hmm_dir, lm=lmd, dict=dictd, logfn=self._logfiles[TranscriptionMode.NORMAL]) + def __del__(self): + for filename in self._logfiles.values(): + os.remove(filename) + + @classmethod + def get_config(cls): #FIXME: Replace this as soon as we have a config module + config = {} + # HMM dir # Try to get hmm_dir from config profile_path = os.path.join(os.path.dirname(__file__), 'profile.yml') if os.path.exists(profile_path): with open(profile_path, 'r') as f: profile = yaml.safe_load(f) - if 'pocketsphinx' in profile and 'hmm_dir' in profile['pocketsphinx']: - hmm_dir = profile['pocketsphinx']['hmm_dir'] - - if not hmm_dir: - hmm_dir = "/usr/local/share/pocketsphinx/model/hmm/en_US/hub4wsj_sc_8k" - - with tempfile.NamedTemporaryFile(prefix='psdecoder_music_', suffix='.log', delete=False) as f: - self.logfile_music = f.name - with tempfile.NamedTemporaryFile(prefix='psdecoder_persona_', suffix='.log', delete=False) as f: - self.logfile_persona = f.name - with tempfile.NamedTemporaryFile(prefix='psdecoder_default_', suffix='.log', delete=False) as f: - self.logfile_default = f.name - - if lmd_music and dictd_music: - self.speechRec_music = ps.Decoder(hmm=hmm_dir, lm=lmd_music, dict=dictd_music, logfn=self.logfile_music) - self.speechRec_persona = ps.Decoder( - hmm=hmm_dir, lm=lmd_persona, dict=dictd_persona, logfn=self.logfile_persona) - self.speechRec = ps.Decoder(hmm=hmm_dir, lm=lmd, dict=dictd, logfn=self.logfile_default) - - def __del__(self): - os.remove(self.logfile_music) - os.remove(self.logfile_persona) - os.remove(self.logfile_default) + if 'pocketsphinx' in profile: + if 'hmm_dir' in profile['pocketsphinx']: + config['hmm_dir'] = profile['pocketsphinx']['hmm_dir'] + if 'lmd' in profile['pocketsphinx']: + config['lmd'] = profile['pocketsphinx']['lmd'] + if 'dictd' in profile['pocketsphinx']: + config['dictd'] = profile['pocketsphinx']['dictd'] + if 'lmd_persona' in profile['pocketsphinx']: + config['lmd_persona'] = profile['pocketsphinx']['lmd_persona'] + if 'dictd_persona' in profile['pocketsphinx']: + config['dictd_persona'] = profile['pocketsphinx']['dictd_persona'] + if 'lmd_music' in profile['pocketsphinx']: + config['lmd'] = profile['pocketsphinx']['lmd_music'] + if 'dictd_music' in profile['pocketsphinx']: + config['dictd_music'] = profile['pocketsphinx']['dictd_music'] + return config - def transcribe(self, audio_file_path, PERSONA_ONLY=False, MUSIC=False): + def transcribe(self, fp, mode=TranscriptionMode.NORMAL): """ Performs STT, transcribing an audio file and returning the result. @@ -77,30 +118,27 @@ class PocketSphinxSTT(object): PERSONA_ONLY -- if True, uses the 'Persona' language model and dictionary MUSIC -- if True, uses the 'Music' language model and dictionary """ + decoder = self._decoders[mode] - wavFile = file(audio_file_path, 'rb') - wavFile.seek(44) + fp.seek(44) - if MUSIC: - self.speechRec_music.decode_raw(wavFile) - result = self.speechRec_music.get_hyp() - with open(self.logfile_music, 'r+') as f: + # FIXME: Can't use the Decoder.decode_raw() here, because + # pocketsphinx segfaults with tempfile.SpooledTemporaryFile() + data = fp.read() + decoder.start_utt() + decoder.process_raw(data, False, True) + decoder.end_utt() + + result = decoder.get_hyp() + with open(self._logfiles[mode], 'r+') as f: + if mode == TranscriptionMode.KEYWORD: + modename = "[KEYWORD]" + elif mode == TranscriptionMode.MUSIC: + modename = "[MUSIC]" + else: + modename = "[NORMAL]" for line in f: - self._logger.debug("speechRec_music %s", line.strip()) - f.truncate() - elif PERSONA_ONLY: - self.speechRec_persona.decode_raw(wavFile) - result = self.speechRec_persona.get_hyp() - with open(self.logfile_persona, 'r+') as f: - for line in f: - self._logger.debug("speechRec_persona %s", line.strip()) - f.truncate() - else: - self.speechRec.decode_raw(wavFile) - result = self.speechRec.get_hyp() - with open(self.logfile_default, 'r+') as f: - for line in f: - self._logger.debug("speechRec_default %s", line.strip()) + self._logger.debug("%s %s", modename, line.strip()) f.truncate() print "===================" @@ -109,6 +147,10 @@ class PocketSphinxSTT(object): return [result[0]] + @classmethod + def is_available(cls): + return (pkgutil.get_loader('pocketsphinx') is not None) + """ Speech-To-Text implementation which relies on the Google Speech API. @@ -134,19 +176,34 @@ Excerpt from sample profile.yml: """ -class GoogleSTT(object): +class GoogleSTT(AbstractSTTEngine): - RATE = 16000 + SLUG = 'google' - def __init__(self, api_key, **kwargs): + def __init__(self, api_key=None): #FIXME: get init args from config """ Arguments: api_key - the public api key which allows access to Google APIs """ + if not api_key: + raise ValueError("No Google API Key given") self.api_key = api_key self.http = requests.Session() - def transcribe(self, audio_file_path, PERSONA_ONLY=False, MUSIC=False): + @classmethod + def get_config(cls): #FIXME: Replace this as soon as we have a config module + config = {} + # HMM dir + # Try to get hmm_dir from config + profile_path = os.path.join(os.path.dirname(__file__), 'profile.yml') + if os.path.exists(profile_path): + with open(profile_path, 'r') as f: + profile = yaml.safe_load(f) + if 'keys' in profile and 'GOOGLE_SPEECH' in profile['keys']: + config['api_key'] = profile['keys']['GOOGLE_SPEECH'] + return config + + def transcribe(self, fp, mode=TranscriptionMode.NORMAL): """ Performs STT via the Google Speech API, transcribing an audio file and returning an English string. @@ -154,15 +211,18 @@ class GoogleSTT(object): Arguments: audio_file_path -- the path to the .wav file to be transcribed """ + + wav = wave.open(fp, 'rb') + frame_rate = wav.getframerate() + wav.close() + url = "https://www.google.com/speech-api/v2/recognize?output=json&client=chromium&key=%s&lang=%s&maxresults=6&pfilter=2" % ( self.api_key, "en-us") - wav = open(audio_file_path, 'rb') - data = wav.read() - wav.close() + data = fp.read() try: - headers = {'Content-type': 'audio/l16; rate=%s' % GoogleSTT.RATE} + headers = {'Content-type': 'audio/l16; rate=%s' % frame_rate} response = self.http.post(url, data=data, headers=headers) response.encoding = 'utf-8' response_read = response.text @@ -181,6 +241,10 @@ class GoogleSTT(object): except Exception: traceback.print_exc() + @classmethod + def is_available(cls): + return True + """ Returns a Speech-To-Text engine. @@ -191,13 +255,17 @@ Arguments: engine_type - one of "sphinx" or "google" kwargs - keyword arguments passed to the constructor of the STT engine """ +def get_engines(): + return [stt_engine for stt_engine in AbstractSTTEngine.__subclasses__() if hasattr(stt_engine, 'SLUG') and stt_engine.SLUG] - -def newSTTEngine(engine_type, **kwargs): - t = engine_type.lower() - if t == "sphinx": - return PocketSphinxSTT(**kwargs) - elif t == "google": - return GoogleSTT(**kwargs) +def newSTTEngine(stt_engine, **kwargs): + selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and engine.SLUG == stt_engine, get_engines()) + if len(selected_engines) == 0: + raise ValueError("No STT engine found for slug '%s'" % stt_engine) else: - raise ValueError("Unsupported STT engine type: " + engine_type) + if len(selected_engines) > 1: + print("WARNING: Multiple STT engines found for slug '%s'. This is most certainly a bug." % stt_engine) + engine = selected_engines[0] + if not engine.is_available(): + raise ValueError("STT engine '%s' is not available (due to missing dependencies, missing dependencies, etc.)" % stt_engine) + return engine(**engine.get_config()) diff --git a/client/test.py b/client/test.py index 36e5ef5..501fe6f 100644 --- a/client/test.py +++ b/client/test.py @@ -13,6 +13,7 @@ import vocabcompiler import g2p import brain import jasperpath +import tts from diagnose import Diagnostics DEFAULT_PROFILE = { @@ -22,9 +23,6 @@ DEFAULT_PROFILE = { 'phone_number': '012344321' } -def activeInternet(): - return Diagnostics.check_network_connection() - class UnorderedList(list): def __eq__(self, other): @@ -146,7 +144,7 @@ class TestModules(unittest.TestCase): inputs = [] self.runConversation(query, inputs, Time) - @unittest.skipIf(not activeInternet(), "No internet connection") + @unittest.skipIf(not Diagnostics.check_network_connection(), "No internet connection") def testGmail(self): key = 'gmail_password' if not key in self.profile or not self.profile[key]: @@ -158,7 +156,7 @@ class TestModules(unittest.TestCase): inputs = [] self.runConversation(query, inputs, Gmail) - @unittest.skipIf(not activeInternet(), "No internet connection") + @unittest.skipIf(not Diagnostics.check_network_connection(), "No internet connection") def testHN(self): from modules import HN @@ -170,7 +168,7 @@ class TestModules(unittest.TestCase): outputs = self.runConversation(query, inputs, HN) self.assertTrue("front-page articles" in outputs[1]) - @unittest.skipIf(not activeInternet(), "No internet connection") + @unittest.skipIf(not Diagnostics.check_network_connection(), "No internet connection") def testNews(self): from modules import News @@ -182,7 +180,7 @@ class TestModules(unittest.TestCase): outputs = self.runConversation(query, inputs, News) self.assertTrue("top headlines" in outputs[1]) - @unittest.skipIf(not activeInternet(), "No internet connection") + @unittest.skipIf(not Diagnostics.check_network_connection(), "No internet connection") def testWeather(self): from modules import Weather @@ -193,6 +191,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): @@ -222,7 +225,7 @@ class TestBrain(unittest.TestCase): def testPriority(self): """Does Brain correctly send query to higher-priority module?""" my_brain = TestBrain._emptyBrain() - hn_module = 'modules.HN' + hn_module = 'HN' hn = filter(lambda m: m.__name__ == hn_module, my_brain.modules)[0] with patch.object(hn, 'handle') as mocked_handle: @@ -247,7 +250,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) diff --git a/client/test_mic.py b/client/test_mic.py index 4813e5c..472bf62 100644 --- a/client/test_mic.py +++ b/client/test_mic.py @@ -16,6 +16,9 @@ class Mic: def passiveListen(self, PERSONA): return True, "JASPER" + def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, MUSIC=False): + return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, MUSIC=MUSIC)] + def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): if not LISTEN: return self.inputs[self.idx - 1] diff --git a/client/tts.py b/client/tts.py new file mode 100644 index 0000000..8fef776 --- /dev/null +++ b/client/tts.py @@ -0,0 +1,360 @@ +# -*- 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 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', '-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) + 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') 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(4L) # width of 32 bit audio + 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.") |
