summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/app_utils.py (renamed from client/modules/app_utils.py)0
-rw-r--r--client/brain.py6
-rw-r--r--client/local_mic.py3
-rw-r--r--client/mic.py58
-rw-r--r--client/musicmode.py3
-rw-r--r--client/speaker.py63
-rw-r--r--client/stt.py196
-rw-r--r--client/test.py21
-rw-r--r--client/test_mic.py3
-rw-r--r--client/tts.py360
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.")