From fa52f2684479170e3c8c988ca82f6d4f70ec8c77 Mon Sep 17 00:00:00 2001 From: Gonzalo Odiard Date: Wed, 02 May 2012 18:34:29 +0000 Subject: Add text to speech functionality to Write - SL #3266 Ass discussed with the Learning Team, Write need a inmediate access to Text to Speech, the global tts feature is too indirect. Signed-off-by: Gonzalo Odiard --- diff --git a/AbiWordActivity.py b/AbiWordActivity.py index 3861365..beef626 100644 --- a/AbiWordActivity.py +++ b/AbiWordActivity.py @@ -49,6 +49,8 @@ from toolbar import InsertToolbar from toolbar import ParagraphToolbar from widgets import ExportButtonFactory from port import chooser +import speech +from speechtoolbar import SpeechToolbar logger = logging.getLogger('write-activity') @@ -132,6 +134,13 @@ class AbiWordActivity(activity.Activity): content_box.show_all() self.floating_image = False + if speech.supported: + self.speech_toolbar_button = ToolbarButton(icon_name='speak') + toolbar_box.toolbar.insert(self.speech_toolbar_button, -1) + self.speech_toolbar = SpeechToolbar(self) + self.speech_toolbar_button.set_page(self.speech_toolbar) + self.speech_toolbar_button.show() + separator = gtk.SeparatorToolItem() separator.props.draw = False separator.set_expand(True) @@ -195,6 +204,7 @@ class AbiWordActivity(activity.Activity): if self.abiword_canvas.get_selection('text/plain')[1] == 0: logging.error('Setting default font to Sans in new documents') self.abiword_canvas.set_font_name('Sans') + self.abiword_canvas.moveto_bod() def get_preview(self): if not hasattr(self.abiword_canvas, 'render_page_to_image'): diff --git a/icons/speak.svg b/icons/speak.svg new file mode 100644 index 0000000..5466410 --- /dev/null +++ b/icons/speak.svg @@ -0,0 +1,76 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/speech.py b/speech.py new file mode 100644 index 0000000..d950fbd --- /dev/null +++ b/speech.py @@ -0,0 +1,44 @@ +# Copyright (C) 2008, 2009 James D. Simmons +# Copyright (C) 2009 Aleksey S. Lim +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging + +_logger = logging.getLogger('read-etexts-activity') + +supported = True + +try: + import gst + gst.element_factory_make('espeak') + from speech_gst import * + _logger.info('use gst-plugins-espeak') +except Exception, e: + _logger.info('disable gst-plugins-espeak: %s' % e) + try: + from speech_dispatcher import * + _logger.info('use speech-dispatcher') + except Exception, e: + supported = False + _logger.info('disable speech: %s' % e) + +voice = 'default' +pitch = 0 +rate = 0 + +highlight_cb = None +end_text_cb = None +reset_cb = None diff --git a/speech_dispatcher.py b/speech_dispatcher.py new file mode 100644 index 0000000..4fad27f --- /dev/null +++ b/speech_dispatcher.py @@ -0,0 +1,116 @@ +# Copyright (C) 2008 James D. Simmons +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gtk +import time +import threading +import speechd +import logging + +import speech + +_logger = logging.getLogger('read-etexts-activity') + +done = True + + +def voices(): + try: + client = speechd.SSIPClient('readetextstest') + voices = client.list_synthesis_voices() + client.close() + return voices + except Exception, e: + _logger.warning('speech dispatcher not started: %s' % e) + return [] + + +def say(words): + try: + client = speechd.SSIPClient('readetextstest') + client.set_rate(int(speech.rate)) + client.set_pitch(int(speech.pitch)) + client.set_language(speech.voice[1]) + client.speak(words) + client.close() + except Exception, e: + _logger.warning('speech dispatcher not running: %s' % e) + + +def is_stopped(): + return done + + +def stop(): + global done + done = True + + +def play(words): + global thread + thread = EspeakThread(words) + thread.start() + + +class EspeakThread(threading.Thread): + + def __init__(self, words): + threading.Thread.__init__(self) + self.words = words + + def run(self): + "This is the code that is executed when the start() method is called" + self.client = None + try: + self.client = speechd.SSIPClient('readetexts') + self.client._conn.send_command('SET', speechd.Scope.SELF, + 'SSML_MODE', "ON") + if speech.voice: + self.client.set_language(speech.voice[1]) + self.client.set_rate(speech.rate) + self.client.set_pitch(speech.pitch) + self.client.speak(self.words, self.next_word_cb, + (speechd.CallbackType.INDEX_MARK, + speechd.CallbackType.END)) + global done + done = False + while not done: + time.sleep(0.1) + self.cancel() + self.client.close() + except Exception, e: + _logger.warning('speech-dispatcher client not created: %s' % e) + + def cancel(self): + if self.client: + try: + self.client.cancel() + except Exception, e: + _logger.warning('speech dispatcher cancel failed: %s' % e) + + def next_word_cb(self, type, **kargs): + if type == speechd.CallbackType.INDEX_MARK: + mark = kargs['index_mark'] + word_count = int(mark) + gtk.gdk.threads_enter() + speech.highlight_cb(word_count) + gtk.gdk.threads_leave() + elif type == speechd.CallbackType.END: + gtk.gdk.threads_enter() + speech.reset_cb() + gtk.gdk.threads_leave() + global done + done = True diff --git a/speech_gst.py b/speech_gst.py new file mode 100644 index 0000000..b827ad9 --- /dev/null +++ b/speech_gst.py @@ -0,0 +1,110 @@ +# Copyright (C) 2009 Aleksey S. Lim +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import gst +import logging + +import speech + +_logger = logging.getLogger('read-etexts-activity') + + +def get_all_voices(): + all_voices = {} + for voice in gst.element_factory_make('espeak').props.voices: + name, language, dialect = voice + if dialect != 'none': + all_voices[language + '_' + dialect] = name + else: + all_voices[language] = name + return all_voices + + +def _message_cb(bus, message, pipe): + if message.type == gst.MESSAGE_EOS: + pipe.set_state(gst.STATE_NULL) + if speech.end_text_cb != None: + speech.end_text_cb() + if message.type == gst.MESSAGE_ERROR: + pipe.set_state(gst.STATE_NULL) + if pipe is play_speaker[1]: + speech.reset_cb() + elif message.type == gst.MESSAGE_ELEMENT and \ + message.structure.get_name() == 'espeak-mark': + mark = message.structure['mark'] + speech.highlight_cb(int(mark)) + + +def _create_pipe(): + pipe = gst.Pipeline('pipeline') + + source = gst.element_factory_make('espeak', 'source') + pipe.add(source) + + sink = gst.element_factory_make('autoaudiosink', 'sink') + pipe.add(sink) + source.link(sink) + + bus = pipe.get_bus() + bus.add_signal_watch() + bus.connect('message', _message_cb, pipe) + + return (source, pipe) + + +def _speech(speaker, words): + speaker[0].props.pitch = speech.pitch + speaker[0].props.rate = speech.rate + speaker[0].props.voice = speech.voice[1] + speaker[0].props.text = words + speaker[1].set_state(gst.STATE_NULL) + speaker[1].set_state(gst.STATE_PLAYING) + + +info_speaker = _create_pipe() +play_speaker = _create_pipe() +play_speaker[0].props.track = 2 + + +def voices(): + return info_speaker[0].props.voices + + +def say(words): + _speech(info_speaker, words) + + +def play(words): + _speech(play_speaker, words) + + +def pause(): + play_speaker[1].set_state(gst.STATE_PAUSED) + + +def continue_play(): + play_speaker[1].set_state(gst.STATE_PLAYING) + + +def is_stopped(): + for i in play_speaker[1].get_state(): + if isinstance(i, gst.State) and i == gst.STATE_NULL: + return True + return False + + +def stop(): + play_speaker[1].set_state(gst.STATE_NULL) diff --git a/speechtoolbar.py b/speechtoolbar.py new file mode 100644 index 0000000..ca6eae5 --- /dev/null +++ b/speechtoolbar.py @@ -0,0 +1,194 @@ +# Copyright (C) 2006, Red Hat, Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import os +import simplejson +from gettext import gettext as _ +import logging + +import gtk +import gconf + +from sugar.graphics.toolbutton import ToolButton +from sugar.graphics.toggletoolbutton import ToggleToolButton +from sugar.graphics.combobox import ComboBox +from sugar.graphics.toolcombobox import ToolComboBox + +import speech + + +class SpeechToolbar(gtk.Toolbar): + + def __init__(self, activity): + gtk.Toolbar.__init__(self) + self._activity = activity + if not speech.supported: + return + self.is_paused = False + self._cnf_client = gconf.client_get_default() + self.load_speech_parameters() + + self.sorted_voices = [i for i in speech.voices()] + self.sorted_voices.sort(self.compare_voices) + default = 0 + for voice in self.sorted_voices: + if voice[0] == speech.voice[0]: + break + default = default + 1 + + # Play button + self.play_btn = ToggleToolButton('media-playback-start') + self.play_btn.show() + self.play_btn.connect('toggled', self.play_cb) + self.insert(self.play_btn, -1) + self.play_btn.set_tooltip(_('Play / Pause')) + + # Stop button + self.stop_btn = ToolButton('media-playback-stop') + self.stop_btn.show() + self.stop_btn.connect('clicked', self.stop_cb) + self.stop_btn.set_sensitive(False) + self.insert(self.stop_btn, -1) + self.stop_btn.set_tooltip(_('Stop')) + + self.voice_combo = ComboBox() + for voice in self.sorted_voices: + self.voice_combo.append_item(voice, voice[0]) + self.voice_combo.set_active(default) + + self.voice_combo.connect('changed', self.voice_changed_cb) + combotool = ToolComboBox(self.voice_combo) + self.insert(combotool, -1) + combotool.show() + speech.reset_buttons_cb = self.reset_buttons_cb + speech.end_text_cb = self.reset_buttons_cb + + def compare_voices(self, a, b): + if a[0].lower() == b[0].lower(): + return 0 + if a[0] .lower() < b[0].lower(): + return -1 + if a[0] .lower() > b[0].lower(): + return 1 + + def voice_changed_cb(self, combo): + speech.voice = combo.props.value + speech.say(speech.voice[0]) + self.save_speech_parameters() + + def load_speech_parameters(self): + speech_parameters = {} + data_path = os.path.join(self._activity.get_activity_root(), 'data') + data_file_name = os.path.join(data_path, 'speech_params.json') + if os.path.exists(data_file_name): + f = open(data_file_name, 'r') + try: + speech_parameters = simplejson.load(f) + speech.voice = speech_parameters['voice'] + finally: + f.close() + else: + speech.voice = self.get_default_voice() + logging.error('Default voice %s', speech.voice) + + self._cnf_client.add_dir('/desktop/sugar/speech', + gconf.CLIENT_PRELOAD_NONE) + speech.pitch = self._cnf_client.get_int('/desktop/sugar/speech/pitch') + speech.rate = self._cnf_client.get_int('/desktop/sugar/speech/rate') + self._cnf_client.notify_add('/desktop/sugar/speech/pitch', \ + self.__conf_changed_cb, None) + self._cnf_client.notify_add('/desktop/sugar/speech/rate', \ + self.__conf_changed_cb, None) + + def get_default_voice(self): + """Try to figure out the default voice, from the current locale ($LANG) + Fall back to espeak's voice called Default.""" + voices = speech.get_all_voices() + + locale = os.environ.get('LANG', '') + language_location = locale.split('.', 1)[0].lower() + language = language_location.split('_')[0] + variant = '' + if language_location.find('_') > -1: + variant = language_location.split('_')[1] + # if the language is es but not es_es default to es_la (latin voice) + if language == 'es' and language_location != 'es_es': + language_location = 'es_la' + + best = voices.get(language_location) or voices.get(language) \ + or 'default' + logging.debug('Best voice for LANG %s seems to be %s', + locale, best) + return [best, language, variant] + + def __conf_changed_cb(self, client, connection_id, entry, args): + key = entry.get_key() + value = client.get_int(key) + if key == '/desktop/sugar/speech/pitch': + speech.pitch = value + if key == '/desktop/sugar/speech/rate': + speech.rate = value + + def save_speech_parameters(self): + speech_parameters = {} + speech_parameters['voice'] = speech.voice + data_path = os.path.join(self._activity.get_activity_root(), 'data') + data_file_name = os.path.join(data_path, 'speech_params.json') + f = open(data_file_name, 'w') + try: + simplejson.dump(speech_parameters, f) + finally: + f.close() + + def reset_buttons_cb(self): + logging.error('reset buttons') + self.play_btn.set_named_icon('media-playback-start') + self.stop_btn.set_sensitive(False) + self.play_btn.set_active(False) + self.is_paused = False + + def play_cb(self, widget): + self.stop_btn.set_sensitive(True) + if widget.get_active(): + self.play_btn.set_named_icon('media-playback-pause') + logging.error('Paused %s', self.is_paused) + if not self.is_paused: + # get the text to speech, if there are a selection, + # play selected text, if not, play all + abi = self._activity.abiword_canvas + selection = abi.get_selection('text/plain') + if selection[1] == 0: + # nothing selected + abi.select_all() + text = abi.get_selection('text/plain')[0] + abi.moveto_bod() + else: + text = selection[0] + speech.play(text) + else: + logging.error('Continue play') + speech.continue_play() + else: + self.play_btn.set_named_icon('media-playback-start') + self.is_paused = True + speech.pause() + + def stop_cb(self, widget): + self.stop_btn.set_sensitive(False) + self.play_btn.set_named_icon('media-playback-start') + self.play_btn.set_active(False) + self.is_paused = False + speech.stop() -- cgit v0.9.1