[Sugar-devel] [PATCH Sugar] Implementation of Text to Speech in Sugar Feature - v5
godiard at sugarlabs.org
godiard at sugarlabs.org
Tue Jan 31 14:08:15 EST 2012
From: Gonzalo Odiard <godiard at gmail.com>
This patch implement the text to speech feature using the
gstremer espeak plugin and using a device icon in the frame
to enable the user to configure parameters and play/pause/stop
the spoken text. The configuration is saved in gconf.
gstreamer-plugins-espeak is in use in many activities,
but is added now as a dependency to Sugar.
Signed-off-by: Gonzalo Odiard <gonzalo at laptop.org>
------
* Use the standard MenuItem and removed SugarMenuItem.
* Return to <alt><shift>s shortcut. Pressing the shortcut again
while a text is being played pause the play, to mimic the play/pause button.
---
data/sugar.schemas.in | 24 ++++
extensions/deviceicon/Makefile.am | 1 +
extensions/deviceicon/speech.py | 148 ++++++++++++++++++++++++
extensions/globalkey/Makefile.am | 1 +
extensions/globalkey/speech.py | 29 +++++
src/jarabe/model/Makefile.am | 1 +
src/jarabe/model/speech.py | 230 +++++++++++++++++++++++++++++++++++++
src/jarabe/view/keyhandler.py | 29 +-----
8 files changed, 435 insertions(+), 28 deletions(-)
create mode 100644 extensions/deviceicon/speech.py
create mode 100644 extensions/globalkey/speech.py
create mode 100644 src/jarabe/model/speech.py
diff --git a/data/sugar.schemas.in b/data/sugar.schemas.in
index aaef381..226b41f 100644
--- a/data/sugar.schemas.in
+++ b/data/sugar.schemas.in
@@ -368,5 +368,29 @@
</locale>
</schema>
+ <schema>
+ <key>/schemas/desktop/sugar/speech/pitch</key>
+ <applyto>/desktop/sugar/speech/pitch</applyto>
+ <owner>sugar</owner>
+ <type>int</type>
+ <default>0</default>
+ <locale name="C">
+ <short>Pitch value for the speech sugar service</short>
+ <long>Pitch value used by the speech service in Sugar</long>
+ </locale>
+ </schema>
+
+ <schema>
+ <key>/schemas/desktop/sugar/speech/rate</key>
+ <applyto>/desktop/sugar/speech/rate</applyto>
+ <owner>sugar</owner>
+ <type>int</type>
+ <default>0</default>
+ <locale name="C">
+ <short>Rate value for the speech sugar service</short>
+ <long>Rate value used by the speech service in Sugar</long>
+ </locale>
+ </schema>
+
</schemalist>
</gconfschemafile>
diff --git a/extensions/deviceicon/Makefile.am b/extensions/deviceicon/Makefile.am
index 118d866..7ed1f77 100644
--- a/extensions/deviceicon/Makefile.am
+++ b/extensions/deviceicon/Makefile.am
@@ -5,5 +5,6 @@ sugar_PYTHON = \
battery.py \
network.py \
speaker.py \
+ speech.py \
touchpad.py \
volume.py
diff --git a/extensions/deviceicon/speech.py b/extensions/deviceicon/speech.py
new file mode 100644
index 0000000..6b8f915
--- /dev/null
+++ b/extensions/deviceicon/speech.py
@@ -0,0 +1,148 @@
+# Copyright (C) 2011 One Laptop Per Child
+#
+# 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
+
+from gettext import gettext as _
+
+import glib
+import gtk
+import gconf
+import gobject
+
+from sugar.graphics.icon import Icon
+from sugar.graphics.tray import TrayIcon
+from sugar.graphics.palette import Palette
+from sugar.graphics.xocolor import XoColor
+from sugar.graphics.menuitem import MenuItem
+from sugar.graphics import style
+
+from jarabe.frame.frameinvoker import FrameWidgetInvoker
+from jarabe.model import speech
+
+
+_ICON_NAME = 'microphone'
+
+
+class SpeechDeviceView(TrayIcon):
+
+ FRAME_POSITION_RELATIVE = 150
+
+ def __init__(self):
+ client = gconf.client_get_default()
+ self._color = XoColor(client.get_string('/desktop/sugar/user/color'))
+ TrayIcon.__init__(self, icon_name=_ICON_NAME, xo_color=self._color)
+ self.set_palette_invoker(FrameWidgetInvoker(self))
+ self._manager = speech.get_speech_manager()
+ self._icon_widget.connect('button-release-event',
+ self.__button_release_event_cb)
+
+ def create_palette(self):
+ label = glib.markup_escape_text(_('Speech'))
+ palette = SpeechPalette(label, manager=self._manager)
+ palette.set_group_id('frame')
+ return palette
+
+ def __button_release_event_cb(self, widget, event):
+ self.palette_invoker.notify_right_click()
+ return True
+
+
+class SpeechPalette(Palette):
+
+ def __init__(self, primary_text, manager):
+ Palette.__init__(self, label=primary_text)
+
+ self._manager = manager
+ self._manager.connect('play', self._set_menu_state, 'play')
+ self._manager.connect('stop', self._set_menu_state, 'stop')
+ self._manager.connect('pause', self._set_menu_state, 'pause')
+
+ vbox = gtk.VBox()
+ self.set_content(vbox)
+
+ self._play_icon = Icon(icon_name='player_play')
+ self._pause_icon = Icon(icon_name='player_pause')
+ self._play_pause_menu = MenuItem(text_label=_('Say selected text'))
+ self._play_pause_menu.set_image(self._play_icon)
+ self._play_pause_menu.connect('activate', self.__play_activated_cb)
+ self._play_pause_menu.show()
+
+ self._stop_menu = MenuItem(icon_name='player_stop',
+ text_label=_('Stop playback'))
+ self._stop_menu.connect('activate', self.__stop_activated_cb)
+ self._stop_menu.set_sensitive(False)
+ self._stop_menu.show()
+
+ self.menu.append(self._play_pause_menu)
+ self.menu.append(self._stop_menu)
+
+ self._adj_pitch = gtk.Adjustment(value=self._manager.get_pitch(),
+ lower=self._manager.MIN_PITCH,
+ upper=self._manager.MAX_PITCH)
+ self._hscale_pitch = gtk.HScale(self._adj_pitch)
+ self._hscale_pitch.set_draw_value(False)
+
+ vbox.pack_start(gtk.Label(_('Pitch')), padding=style.DEFAULT_PADDING)
+ vbox.pack_start(self._hscale_pitch)
+
+ self._adj_rate = gtk.Adjustment(value=self._manager.get_rate(),
+ lower=self._manager.MIN_RATE,
+ upper=self._manager.MAX_RATE)
+ self._hscale_rate = gtk.HScale(self._adj_rate)
+ self._hscale_rate.set_draw_value(False)
+
+ vbox.pack_start(gtk.Label(_('Rate')), padding=style.DEFAULT_PADDING)
+ vbox.pack_start(self._hscale_rate)
+ vbox.show_all()
+
+ self._adj_pitch.connect('value_changed', self.__adj_pitch_changed_cb)
+ self._adj_rate.connect('value_changed', self.__adj_rate_changed_cb)
+
+ def __adj_pitch_changed_cb(self, adjustement):
+ self._manager.set_pitch(int(adjustement.value))
+
+ def __adj_rate_changed_cb(self, adjustement):
+ self._manager.set_rate(int(adjustement.value))
+
+ def __play_activated_cb(self, widget):
+ if self._manager.is_paused:
+ self._manager.restart()
+ elif not self._manager.is_playing:
+ self._manager.say_selected_text()
+ else:
+ self._manager.pause()
+
+ def __stop_activated_cb(self, widget):
+ self._manager.stop()
+
+ def _set_menu_state(self, manager, signal):
+ if signal == 'play':
+ self._play_pause_menu.set_image(self._pause_icon)
+ self._play_pause_menu.set_label(_('Pause playback'))
+ self._stop_menu.set_sensitive(True)
+
+ elif signal == 'pause':
+ self._play_pause_menu.set_image(self._play_icon)
+ self._play_pause_menu.set_label(_('Say selected text'))
+ self._stop_menu.set_sensitive(True)
+
+ elif signal == 'stop':
+ self._play_pause_menu.set_image(self._play_icon)
+ self._play_pause_menu.set_label(_('Say selected text'))
+ self._stop_menu.set_sensitive(False)
+
+
+def setup(tray):
+ tray.add_device(SpeechDeviceView())
diff --git a/extensions/globalkey/Makefile.am b/extensions/globalkey/Makefile.am
index 69afac2..b6cbbd6 100644
--- a/extensions/globalkey/Makefile.am
+++ b/extensions/globalkey/Makefile.am
@@ -3,4 +3,5 @@ sugardir = $(pkgdatadir)/extensions/globalkey
sugar_PYTHON = \
__init__.py \
screenshot.py \
+ speech.py \
viewsource.py
diff --git a/extensions/globalkey/speech.py b/extensions/globalkey/speech.py
new file mode 100644
index 0000000..2879b69
--- /dev/null
+++ b/extensions/globalkey/speech.py
@@ -0,0 +1,29 @@
+# Copyright (C) 2011 One Laptop Per Child
+#
+# 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
+
+from jarabe.model import speech
+
+BOUND_KEYS = ['<alt><shift>s']
+
+
+def handle_key_press(key):
+ manager = speech.get_speech_manager()
+ if manager.is_paused:
+ manager.restart()
+ elif not manager.is_playing:
+ manager.say_selected_text()
+ else:
+ manager.pause()
diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am
index 92e8712..2fc6b1c 100644
--- a/src/jarabe/model/Makefile.am
+++ b/src/jarabe/model/Makefile.am
@@ -16,4 +16,5 @@ sugar_PYTHON = \
screen.py \
session.py \
sound.py \
+ speech.py \
telepathyclient.py
diff --git a/src/jarabe/model/speech.py b/src/jarabe/model/speech.py
new file mode 100644
index 0000000..ffc108c
--- /dev/null
+++ b/src/jarabe/model/speech.py
@@ -0,0 +1,230 @@
+# Copyright (C) 2011 One Laptop Per Child
+#
+# 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 logging
+
+import gconf
+import gst
+import gtk
+import gobject
+
+
+DEFAULT_PITCH = 0
+
+
+DEFAULT_RATE = 0
+
+_speech_manager = None
+
+
+class SpeechManager(gobject.GObject):
+
+ __gtype_name__ = 'SpeechManager'
+
+ __gsignals__ = {
+ 'play': (gobject.SIGNAL_RUN_FIRST, None, []),
+ 'pause': (gobject.SIGNAL_RUN_FIRST, None, []),
+ 'stop': (gobject.SIGNAL_RUN_FIRST, None, [])
+ }
+
+ MIN_PITCH = -100
+ MAX_PITCH = 100
+
+ MIN_RATE = -100
+ MAX_RATE = 100
+
+ def __init__(self, **kwargs):
+ gobject.GObject.__init__(self, **kwargs)
+ self._player = _GstSpeechPlayer()
+ self._player.connect('play', self._update_state, 'play')
+ self._player.connect('stop', self._update_state, 'stop')
+ self._player.connect('pause', self._update_state, 'pause')
+ self._voice_name = self._player.get_default_voice()
+ self._pitch = DEFAULT_PITCH
+ self._rate = DEFAULT_RATE
+ self._is_playing = False
+ self._is_paused = False
+ self.restore()
+
+ def _update_state(self, player, signal):
+ self._is_playing = (signal == 'play')
+ self._is_paused = (signal == 'pause')
+ self.emit(signal)
+
+ def get_is_playing(self):
+ return self._is_playing
+
+ is_playing = gobject.property(type=bool, getter=get_is_playing,
+ setter=None, default=False)
+
+ def get_is_paused(self):
+ return self._is_paused
+
+ is_paused = gobject.property(type=bool, getter=get_is_paused,
+ setter=None, default=False)
+
+ def get_pitch(self):
+ return self._pitch
+
+ def get_rate(self):
+ return self._rate
+
+ def set_pitch(self, pitch):
+ self._pitch = pitch
+ self.save()
+
+ def set_rate(self, rate):
+ self._rate = rate
+ self.save()
+
+ def say_text(self, text):
+ if text:
+ self._player.speak(self._pitch, self._rate, self._voice_name, text)
+
+ def say_selected_text(self):
+ clipboard = gtk.clipboard_get(selection='PRIMARY')
+ clipboard.request_text(self.__primary_selection_cb)
+
+ def pause(self):
+ self._player.pause_sound_device()
+
+ def restart(self):
+ self._player.restart_sound_device()
+
+ def stop(self):
+ self._player.stop_sound_device()
+
+ def __primary_selection_cb(self, clipboard, text, user_data):
+ self.say_text(text)
+
+ def save(self):
+ client = gconf.client_get_default()
+ client.set_int('/desktop/sugar/speech/pitch', self._pitch)
+ client.set_int('/desktop/sugar/speech/rate', self._rate)
+ logging.debug('saving speech configuration pitch %s rate %s',
+ self._pitch, self._rate)
+
+ def restore(self):
+ client = gconf.client_get_default()
+ self._pitch = client.get_int('/desktop/sugar/speech/pitch')
+ self._rate = client.get_int('/desktop/sugar/speech/rate')
+ logging.debug('loading speech configuration pitch %s rate %s',
+ self._pitch, self._rate)
+
+
+class _GstSpeechPlayer(gobject.GObject):
+
+ __gsignals__ = {
+ 'play': (gobject.SIGNAL_RUN_FIRST, None, []),
+ 'pause': (gobject.SIGNAL_RUN_FIRST, None, []),
+ 'stop': (gobject.SIGNAL_RUN_FIRST, None, [])
+ }
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self._pipeline = None
+
+ def restart_sound_device(self):
+ if self._pipeline is None:
+ logging.debug('Trying to restart not initialized sound device')
+ return
+
+ self._pipeline.set_state(gst.STATE_PLAYING)
+ self.emit('play')
+
+ def pause_sound_device(self):
+ if self._pipeline is None:
+ return
+
+ self._pipeline.set_state(gst.STATE_PAUSED)
+ self.emit('pause')
+
+ def stop_sound_device(self):
+ if self._pipeline is None:
+ return
+
+ self._pipeline.set_state(gst.STATE_NULL)
+ self.emit('stop')
+
+ def make_pipeline(self, command):
+ if self._pipeline is not None:
+ self.stop_sound_device()
+ del self._pipeline
+
+ self._pipeline = gst.parse_launch(command)
+
+ bus = self._pipeline.get_bus()
+ bus.add_signal_watch()
+ bus.connect('message::element', self.__pipe_message_cb)
+
+ def __pipe_message_cb(self, bus, message):
+ if message.structure.get_name() == 'espeak-mark' and \
+ message.structure['mark'] == 'end':
+ self.emit('stop')
+
+ def speak(self, pitch, rate, voice_name, text):
+ # TODO workaround for http://bugs.sugarlabs.org/ticket/1801
+ if not [i for i in text if i.isalnum()]:
+ return
+ text = text + '<mark name="end>"></mark>'
+
+ self.make_pipeline('espeak name=espeak ! autoaudiosink')
+ src = self._pipeline.get_by_name('espeak')
+
+ src.props.text = text
+ src.props.pitch = pitch
+ src.props.rate = rate
+ src.props.voice = voice_name
+ src.props.track = 2 # track for marks
+
+ self.restart_sound_device()
+
+ def get_all_voices(self):
+ 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 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 = self.get_all_voices()
+
+ locale = os.environ.get('LANG', '')
+ language_location = locale.split('.', 1)[0].lower()
+ language = language_location.split('_')[0]
+ # 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
+
+
+def get_speech_manager():
+ global _speech_manager
+
+ if _speech_manager is None:
+ _speech_manager = SpeechManager()
+ return _speech_manager
diff --git a/src/jarabe/view/keyhandler.py b/src/jarabe/view/keyhandler.py
index d79bfe6..a71f260 100644
--- a/src/jarabe/view/keyhandler.py
+++ b/src/jarabe/view/keyhandler.py
@@ -60,13 +60,9 @@ _actions_table = {
'<alt><shift>f': 'frame',
'<alt><shift>q': 'quit_emulator',
'XF86Search': 'open_search',
- '<alt><shift>o': 'open_search',
- '<alt><shift>s': 'say_text',
+ '<alt><shift>o': 'open_search'
}
-SPEECH_DBUS_SERVICE = 'org.laptop.Speech'
-SPEECH_DBUS_PATH = '/org/laptop/Speech'
-SPEECH_DBUS_INTERFACE = 'org.laptop.Speech'
_instance = None
@@ -77,7 +73,6 @@ class KeyHandler(object):
self._key_pressed = None
self._keycode_pressed = 0
self._keystate_pressed = 0
- self._speech_proxy = None
self._key_grabber = KeyGrabber()
self._key_grabber.connect('key-pressed',
@@ -114,28 +109,6 @@ class KeyHandler(object):
sound.set_volume(volume)
sound.set_muted(volume == 0)
- def _get_speech_proxy(self):
- if self._speech_proxy is None:
- bus = dbus.SessionBus()
- speech_obj = bus.get_object(SPEECH_DBUS_SERVICE, SPEECH_DBUS_PATH,
- follow_name_owner_changes=True)
- self._speech_proxy = dbus.Interface(speech_obj,
- SPEECH_DBUS_INTERFACE)
- return self._speech_proxy
-
- def _on_speech_err(self, ex):
- logging.error('An error occurred with the ESpeak service: %r', ex)
-
- def _primary_selection_cb(self, clipboard, text, user_data):
- logging.debug('KeyHandler._primary_selection_cb: %r', text)
- if text:
- self._get_speech_proxy().SayText(text, reply_handler=lambda: None,
- error_handler=self._on_speech_err)
-
- def handle_say_text(self, event_time):
- clipboard = gtk.clipboard_get(selection='PRIMARY')
- clipboard.request_text(self._primary_selection_cb)
-
def handle_previous_window(self, event_time):
self._tabbing_handler.previous_activity(event_time)
--
1.7.7.6
More information about the Sugar-devel
mailing list