diff --git a/docs/changelog.rst b/docs/changelog.rst index 82c0edb..a025005 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,7 @@ Version 1.2 ~~~~~~~~~~~ * Fixed voice selection to use VoiceLocaleIdentifier on OS X instead of deprecated VoiceLanguage +* Introduced speakToFile function to driver api to write speech direct to disk as audio file. Support for OS X only at this stage Version 1.1 ~~~~~~~~~~~ diff --git a/docs/drivers.rst b/docs/drivers.rst index b2520ca..8506718 100644 --- a/docs/drivers.rst +++ b/docs/drivers.rst @@ -54,6 +54,16 @@ All drivers must implement the following factory function and driver interface. :param text: Text to speak. :param name: Name to associate with the utterance. Included in notifications about this utterance. + + .. method:: speakToFile(text : unicode, filename : string, name : string) -> None + + Immediately saves an utterance to the filepath specified. The speech must be output according to the current property values applied at the time of this invocation. Before this method returns, it must invoke :meth:`pyttsx.driver.DriverProxy.setBusy` with value :const:`True` to stall further processing of the command queue until the output completes or is interrupted. + + This method must trigger one and only one `started-utterance` notification when output begins, one `started-word` notification at the start of each word in the utterance, and a `finished-utterance` notification when output completes. + + :param text: Text to say. + :param filepath: Location to save the audio file to. + :param name: Name to associate with the utterance. Included in notifications about this utterance. .. method:: setProperty(name : string, value : object) -> None diff --git a/pyttsx/driver.py b/pyttsx/driver.py index 38de96e..0f7c007 100644 --- a/pyttsx/driver.py +++ b/pyttsx/driver.py @@ -146,6 +146,19 @@ def say(self, text, name): @type name: str ''' self._push(self._driver.say, (text,), name) + + def speakToFile(self, text, filepath, name): + ''' + Called by the engine to push a speakToFile command onto the queue. + + @param text: Text to say + @type text: unicode + @param filepath: Location to save the audio file to + @type filepath: str + @param name: Name to associate with the utterance + @type name: str + ''' + self._push(self._driver.speakToFile, (text, filepath), name) def stop(self): ''' diff --git a/pyttsx/drivers/dummy.py b/pyttsx/drivers/dummy.py index 92c04ee..a08f278 100644 --- a/pyttsx/drivers/dummy.py +++ b/pyttsx/drivers/dummy.py @@ -139,6 +139,9 @@ def say(self, text): pass self._proxy.notify('finished-utterance', completed=True) self._proxy.setBusy(False) + + def speakToFile(self, text, filename): + raise NotImplementedError('Not implemented in this driver') def stop(self): ''' diff --git a/pyttsx/drivers/espeak.py b/pyttsx/drivers/espeak.py index 106b6d3..0a26fd8 100644 --- a/pyttsx/drivers/espeak.py +++ b/pyttsx/drivers/espeak.py @@ -52,6 +52,9 @@ def say(self, text): self._proxy.setBusy(True) self._proxy.notify('started-utterance') _espeak.Synth(text, flags=_espeak.ENDPAUSE) + + def speakToFile(self, text, filename): + raise NotImplementedError('Not implemented in this driver') def stop(self): if _espeak.IsPlaying(): diff --git a/pyttsx/drivers/nsss.py b/pyttsx/drivers/nsss.py index 4f23daf..5ba672d 100644 --- a/pyttsx/drivers/nsss.py +++ b/pyttsx/drivers/nsss.py @@ -59,6 +59,13 @@ def say(self, text): self._completed = True self._proxy.notify('started-utterance') self._tts.startSpeakingString_(unicode(text)) + + def speakToFile(self, text, filepath): + f = NSURL.fileURLWithPath_(filepath) + self._proxy.setBusy(True) + self._completed = True + self._proxy.notify('started-utterance') + self._tts.startSpeakingString_toURL_(unicode(text), f) def stop(self): if self._proxy.isBusy(): diff --git a/pyttsx/drivers/sapi5.py b/pyttsx/drivers/sapi5.py index 9923ddd..2784e05 100644 --- a/pyttsx/drivers/sapi5.py +++ b/pyttsx/drivers/sapi5.py @@ -63,6 +63,9 @@ def say(self, text): self._proxy.notify('started-utterance') self._speaking = True self._tts.Speak(unicode(text), 19) + + def speakToFile(self, text, filename): + raise NotImplementedError('Not implemented in this driver') def stop(self): if not self._speaking: diff --git a/pyttsx/engine.py b/pyttsx/engine.py index 77b016d..397aad2 100644 --- a/pyttsx/engine.py +++ b/pyttsx/engine.py @@ -112,6 +112,20 @@ def say(self, text, name=None): @type name: str ''' self.proxy.say(text, name) + + def speakToFile(self, text, filepath, name=None): + ''' + Adds an utterance for transcribing to disk to the event queue. + + @param text: Text to say + @type text: unicode + @param filepath: Location to save the audio file to + @type filepath: str + @param name: Name to associate with this utterance. Included in + notifications about this utterance. + @type name: str + ''' + self.proxy.speakToFile(text, filepath, name) def stop(self): ''' diff --git a/tests/unit/test_base_commands.py b/tests/unit/test_base_commands.py new file mode 100644 index 0000000..685202a --- /dev/null +++ b/tests/unit/test_base_commands.py @@ -0,0 +1,57 @@ +''' +Common base code for tests of pyttsx commands + +Created on Oct 13, 2013 + +@author: humbled +''' +import unittest +import pyttsx + +class BaseCommandTest(unittest.TestCase): + + def setUp(self): + self.correct = [] + for utter, name in zip(self.utters, self.names): + events = [{'type' : 'started-utterance'}] + last = 0 + for word in utter.split(' '): + event = {'type' : 'started-word'} + event['length'] = len(word) + event['location'] = last + events.append(event) + last += len(word) + 1 + events.append({'type' : 'finished-utterance', 'completed' : True}) + for event in events: + event['name'] = name + self.correct.append(events) + + self.events = [] + self.engine = pyttsx.init(debug=False) + self.engine.connect('started-utterance', self._onUtterStart) + self.engine.connect('started-word', self._onUtterWord) + self.engine.connect('finished-utterance', self._onUtterEnd) + self.engine.connect('error', self._onUtterError) + + def tearDown(self): + del self.engine + + def _onUtterStart(self, **kwargs): + event = {'type' : 'started-utterance'} + event.update(kwargs) + self.events.append(event) + + def _onUtterWord(self, **kwargs): + event = {'type' : 'started-word'} + event.update(kwargs) + self.events.append(event) + + def _onUtterEnd(self, **kwargs): + event = {'type' : 'finished-utterance'} + event.update(kwargs) + self.events.append(event) + + def _onUtterError(self, **kwargs): + event = {'type' : 'error'} + event.update(kwargs) + self.events.append(event) \ No newline at end of file diff --git a/tests/unit/test_say.py b/tests/unit/test_say.py index cb9d953..8bea6d5 100644 --- a/tests/unit/test_say.py +++ b/tests/unit/test_say.py @@ -20,57 +20,13 @@ import pyttsx import itertools -class TestSay(unittest.TestCase): +from test_base_commands import BaseCommandTest + +class TestSay(BaseCommandTest): utters = ['This is the first utterance', 'The second is an utterance as well'] names = ['utter1', 'utter2'] - def setUp(self): - self.correct = [] - for utter, name in zip(self.utters, self.names): - events = [{'type' : 'started-utterance'}] - last = 0 - for word in utter.split(' '): - event = {'type' : 'started-word'} - event['length'] = len(word) - event['location'] = last - events.append(event) - last += len(word) + 1 - events.append({'type' : 'finished-utterance', 'completed' : True}) - for event in events: - event['name'] = name - self.correct.append(events) - - self.events = [] - self.engine = pyttsx.init(debug=False) - self.engine.connect('started-utterance', self._onUtterStart) - self.engine.connect('started-word', self._onUtterWord) - self.engine.connect('finished-utterance', self._onUtterEnd) - self.engine.connect('error', self._onUtterError) - - def tearDown(self): - del self.engine - - def _onUtterStart(self, **kwargs): - event = {'type' : 'started-utterance'} - event.update(kwargs) - self.events.append(event) - - def _onUtterWord(self, **kwargs): - event = {'type' : 'started-word'} - event.update(kwargs) - self.events.append(event) - - def _onUtterEnd(self, **kwargs): - event = {'type' : 'finished-utterance'} - event.update(kwargs) - self.events.append(event) - - def _onUtterError(self, **kwargs): - event = {'type' : 'error'} - event.update(kwargs) - self.events.append(event) - def testSay(self): self.engine.say(self.utters[0], self.names[0]) self.engine.runAndWait() diff --git a/tests/unit/test_speakToFile.py b/tests/unit/test_speakToFile.py new file mode 100644 index 0000000..f4458f4 --- /dev/null +++ b/tests/unit/test_speakToFile.py @@ -0,0 +1,51 @@ +''' +Created on Oct 13, 2013 + +@author: humbled +''' +import unittest +import test_setup +import itertools + +from test_base_commands import BaseCommandTest +import tempfile +import os +import logging + +class TestSpeakToFile(BaseCommandTest): + utters = ['This is the first utterance', + 'The second is an utterance as well'] + # Filenames to save to in tests that will get saved in tmp dir (full paths at self.filepaths) + _filenames = ['test_one.wav', 'test_two.wav'] + names = ['utter1', 'utter2'] + + def setUp(self): + self._tmpdir = tempfile.mkdtemp(prefix="pyttsx_test") + self.filepaths = [os.path.join(self._tmpdir, f) for f in self._filenames] + super(TestSpeakToFile, self).setUp() + + def testSpeakToFile(self): + filepath = self.filepaths[0] + self.engine.speakToFile(self.utters[0], filepath, self.names[0]) + logging.info("test attempting to save speech file to: %s" % filepath) + self.engine.runAndWait() + + # number of events check + self.assert_(len(self.events) == len(self.correct[0])) + # event data check + for cevent, tevent in zip(self.correct[0], self.events): + self.assertEqual(cevent, tevent) + + # Now check file is there + self.assert_(os.path.exists(filepath)) + # And is a file + self.assert_(os.path.isfile(filepath)) + self.assert_(os.path.getsize(filepath) > 0) + +def suite(): + suite = unittest.TestLoader().loadTestsFromTestCase(TestSpeakToFile) + return suite + +if __name__ == '__main__': + logging.basicConfig(level = logging.INFO) + unittest.TextTestRunner(verbosity=2).run(suite()) \ No newline at end of file