1 |
# Speak.activity |
2 |
# A simple front end to the espeak text-to-speech engine on the XO laptop |
3 |
# http://wiki.laptop.org/go/Speak |
4 |
# |
5 |
# Copyright (C) 2008 Joshua Minor |
6 |
# This file is part of Speak.activity |
7 |
# |
8 |
# Parts of Speak.activity are based on code from Measure.activity |
9 |
# Copyright (C) 2007 Arjun Sarwal - arjun@laptop.org |
10 |
# |
11 |
# Speak.activity is free software: you can redistribute it and/or modify |
12 |
# it under the terms of the GNU General Public License as published by |
13 |
# the Free Software Foundation, either version 3 of the License, or |
14 |
# (at your option) any later version. |
15 |
# |
16 |
# Speak.activity is distributed in the hope that it will be useful, |
17 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
18 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
19 |
# GNU General Public License for more details. |
20 |
# |
21 |
# You should have received a copy of the GNU General Public License |
22 |
# along with Speak.activity. If not, see <http://www.gnu.org/licenses/>. |
23 |
|
24 |
|
25 |
from sugar.activity import activity |
26 |
from sugar.presence import presenceservice |
27 |
import logging |
28 |
import gtk |
29 |
import gobject |
30 |
import pango |
31 |
import cjson |
32 |
from gettext import gettext as _ |
33 |
|
34 |
from sugar.graphics.toolbutton import ToolButton |
35 |
from sugar.graphics.toggletoolbutton import ToggleToolButton |
36 |
from sugar.graphics.radiotoolbutton import RadioToolButton |
37 |
|
38 |
from toolkit.toolitem import ToolWidget |
39 |
from toolkit.combobox import ComboBox |
40 |
from toolkit.toolbarbox import ToolbarBox |
41 |
from toolkit.activity import SharedActivity |
42 |
from toolkit.activity_widgets import * |
43 |
|
44 |
import eye |
45 |
import glasses |
46 |
import mouth |
47 |
import fft_mouth |
48 |
import waveform_mouth |
49 |
import voice |
50 |
import face |
51 |
import brain |
52 |
import chat |
53 |
import espeak |
54 |
from messenger import Messenger, SERVICE |
55 |
|
56 |
logger = logging.getLogger('speak') |
57 |
|
58 |
MODE_TYPE = 1 |
59 |
MODE_BOT = 2 |
60 |
MODE_CHAT = 3 |
61 |
|
62 |
|
63 |
class SpeakActivity(SharedActivity): |
64 |
def __init__(self, handle): |
65 |
self.notebook = gtk.Notebook() |
66 |
|
67 |
SharedActivity.__init__(self, self.notebook, SERVICE, handle) |
68 |
|
69 |
self._mode = MODE_TYPE |
70 |
self.numeyesadj = None |
71 |
|
72 |
# make an audio device for playing back and rendering audio |
73 |
self.connect("notify::active", self._activeCb) |
74 |
|
75 |
# make a box to type into |
76 |
self.entrycombo = gtk.combo_box_entry_new_text() |
77 |
self.entrycombo.connect("changed", self._combo_changed_cb) |
78 |
self.entry = self.entrycombo.child |
79 |
self.entry.set_editable(True) |
80 |
self.entry.connect('activate', self._entry_activate_cb) |
81 |
self.entry.connect("key-press-event", self._entry_key_press_cb) |
82 |
self.input_font = pango.FontDescription(str='sans bold 24') |
83 |
self.entry.modify_font(self.input_font) |
84 |
|
85 |
self.face = face.View() |
86 |
self.face.show() |
87 |
|
88 |
# layout the screen |
89 |
box = gtk.VBox(homogeneous=False) |
90 |
box.pack_start(self.face) |
91 |
box.pack_start(self.entrycombo, expand=False) |
92 |
|
93 |
self.add_events(gtk.gdk.POINTER_MOTION_HINT_MASK |
94 |
| gtk.gdk.POINTER_MOTION_MASK) |
95 |
self.connect("motion_notify_event", self._mouse_moved_cb) |
96 |
|
97 |
box.add_events(gtk.gdk.BUTTON_PRESS_MASK) |
98 |
box.connect("button_press_event", self._mouse_clicked_cb) |
99 |
|
100 |
# desktop |
101 |
self.notebook.show() |
102 |
self.notebook.props.show_border=False |
103 |
self.notebook.props.show_tabs=False |
104 |
|
105 |
box.show_all() |
106 |
self.notebook.append_page(box) |
107 |
|
108 |
self.chat = chat.View() |
109 |
self.chat.show_all() |
110 |
self.notebook.append_page(self.chat) |
111 |
|
112 |
# make the text box active right away |
113 |
self.entry.grab_focus() |
114 |
|
115 |
self.entry.connect("move-cursor", self._cursor_moved_cb) |
116 |
self.entry.connect("changed", self._cursor_moved_cb) |
117 |
|
118 |
# toolbar |
119 |
|
120 |
toolbox = ToolbarBox() |
121 |
|
122 |
toolbox.toolbar.insert(ActivityToolbarButton(self), -1) |
123 |
|
124 |
separator = gtk.SeparatorToolItem() |
125 |
separator.set_draw(False) |
126 |
toolbox.toolbar.insert(separator, -1) |
127 |
|
128 |
self.voices = ComboBox() |
129 |
for name in sorted(voice.allVoices().keys()): |
130 |
self.voices.append_item(voice.allVoices()[name], name) |
131 |
self.voices.select(voice.defaultVoice()) |
132 |
all_voices = self.voices.get_model() |
133 |
brain_voices = brain.get_voices() |
134 |
|
135 |
mode_type = RadioToolButton( |
136 |
named_icon='mode-type', |
137 |
tooltip=_('Type something to hear it')) |
138 |
mode_type.connect('toggled', self.__toggled_mode_type_cb, all_voices) |
139 |
toolbox.toolbar.insert(mode_type, -1) |
140 |
|
141 |
mode_robot = RadioToolButton( |
142 |
named_icon='mode-robot', |
143 |
group=mode_type, |
144 |
tooltip=_('Ask robot any question')) |
145 |
mode_robot.connect('toggled', self.__toggled_mode_robot_cb, |
146 |
brain_voices) |
147 |
toolbox.toolbar.insert(mode_robot, -1) |
148 |
|
149 |
mode_chat = RadioToolButton( |
150 |
named_icon='mode-chat', |
151 |
group=mode_type, |
152 |
tooltip=_('Voice chat')) |
153 |
mode_chat.connect('toggled', self.__toggled_mode_chat_cb, all_voices) |
154 |
toolbox.toolbar.insert(mode_chat, -1) |
155 |
|
156 |
separator = gtk.SeparatorToolItem() |
157 |
toolbox.toolbar.insert(separator, -1) |
158 |
|
159 |
voices_toolitem = ToolWidget(widget=self.voices) |
160 |
toolbox.toolbar.insert(voices_toolitem, -1) |
161 |
|
162 |
voice_button = ToolbarButton( |
163 |
page=self.make_voice_bar(), |
164 |
label=_('Voice'), |
165 |
icon_name='voice') |
166 |
toolbox.toolbar.insert(voice_button, -1) |
167 |
|
168 |
face_button = ToolbarButton( |
169 |
page=self.make_face_bar(), |
170 |
label=_('Face'), |
171 |
icon_name='face') |
172 |
toolbox.toolbar.insert(face_button, -1) |
173 |
|
174 |
separator = gtk.SeparatorToolItem() |
175 |
separator.set_draw(False) |
176 |
separator.set_expand(True) |
177 |
toolbox.toolbar.insert(separator, -1) |
178 |
|
179 |
toolbox.toolbar.insert(StopButton(self), -1) |
180 |
|
181 |
toolbox.show_all() |
182 |
self.toolbar_box = toolbox |
183 |
|
184 |
def new_instance(self): |
185 |
self.voices.connect('changed', self.__changed_voices_cb) |
186 |
self.pitchadj.connect("value_changed", self.pitch_adjusted_cb, self.pitchadj) |
187 |
self.rateadj.connect("value_changed", self.rate_adjusted_cb, self.rateadj) |
188 |
self.mouth_shape_combo.connect('changed', self.mouth_changed_cb, False) |
189 |
self.mouth_changed_cb(self.mouth_shape_combo, True) |
190 |
self.numeyesadj.connect("value_changed", self.eyes_changed_cb, False) |
191 |
self.eye_shape_combo.connect('changed', self.eyes_changed_cb, False) |
192 |
self.eyes_changed_cb(None, True) |
193 |
|
194 |
self.face.look_ahead() |
195 |
|
196 |
# say hello to the user |
197 |
presenceService = presenceservice.get_instance() |
198 |
xoOwner = presenceService.get_owner() |
199 |
self.face.say_notification(_("Hello %s. Please Type something.") \ |
200 |
% xoOwner.props.nick) |
201 |
|
202 |
def resume_instance(self, file_path): |
203 |
cfg = cjson.decode(file(file_path, 'r').read()) |
204 |
|
205 |
status = self.face.status = face.Status().deserialize(cfg['status']) |
206 |
self.voices.select(status.voice) |
207 |
self.pitchadj.value = self.face.status.pitch |
208 |
self.rateadj.value = self.face.status.rate |
209 |
self.mouth_shape_combo.select(status.mouth) |
210 |
self.eye_shape_combo.select(status.eyes[0]) |
211 |
self.numeyesadj.value = len(status.eyes) |
212 |
|
213 |
self.entry.props.text = cfg['text'] |
214 |
for i in cfg['history']: |
215 |
self.entrycombo.append_text(i) |
216 |
|
217 |
self.new_instance() |
218 |
|
219 |
def save_instance(self, file_path): |
220 |
cfg = {'status': self.face.status.serialize(), |
221 |
'text': self.entry.props.text, |
222 |
'history': map(lambda i: i[0], self.entrycombo.get_model())} |
223 |
file(file_path, 'w').write(cjson.encode(cfg)) |
224 |
|
225 |
def share_instance(self, connection, is_initiator): |
226 |
self.chat.messenger = Messenger(connection, is_initiator, self.chat) |
227 |
|
228 |
def _cursor_moved_cb(self, entry, *ignored): |
229 |
# make the eyes track the motion of the text cursor |
230 |
index = entry.props.cursor_position |
231 |
layout = entry.get_layout() |
232 |
pos = layout.get_cursor_pos(index) |
233 |
x = pos[0][0] / pango.SCALE - entry.props.scroll_offset |
234 |
y = entry.get_allocation().y |
235 |
self.face.look_at(pos=(x, y)) |
236 |
|
237 |
def get_mouse(self): |
238 |
display = gtk.gdk.display_get_default() |
239 |
screen, mouseX, mouseY, modifiers = display.get_pointer() |
240 |
return mouseX, mouseY |
241 |
|
242 |
def _mouse_moved_cb(self, widget, event): |
243 |
# make the eyes track the motion of the mouse cursor |
244 |
self.face.look_at() |
245 |
self.chat.look_at() |
246 |
|
247 |
def _mouse_clicked_cb(self, widget, event): |
248 |
pass |
249 |
|
250 |
def make_voice_bar(self): |
251 |
voicebar = gtk.Toolbar() |
252 |
|
253 |
self.pitchadj = gtk.Adjustment(self.face.status.pitch, 0, |
254 |
espeak.PITCH_MAX, 1, espeak.PITCH_MAX/10, 0) |
255 |
pitchbar = gtk.HScale(self.pitchadj) |
256 |
pitchbar.set_draw_value(False) |
257 |
# pitchbar.set_inverted(True) |
258 |
pitchbar.set_update_policy(gtk.UPDATE_DISCONTINUOUS) |
259 |
pitchbar.set_size_request(240, 15) |
260 |
|
261 |
pitchbar_toolitem = ToolWidget( |
262 |
widget=pitchbar, |
263 |
label_text=_('Pitch:')) |
264 |
voicebar.insert(pitchbar_toolitem, -1) |
265 |
|
266 |
self.rateadj = gtk.Adjustment(self.face.status.rate, 0, espeak.RATE_MAX, |
267 |
1, espeak.RATE_MAX / 10, 0) |
268 |
ratebar = gtk.HScale(self.rateadj) |
269 |
ratebar.set_draw_value(False) |
270 |
# ratebar.set_inverted(True) |
271 |
ratebar.set_update_policy(gtk.UPDATE_DISCONTINUOUS) |
272 |
ratebar.set_size_request(240, 15) |
273 |
|
274 |
ratebar_toolitem = ToolWidget( |
275 |
widget=ratebar, |
276 |
label_text=_('Rate:')) |
277 |
voicebar.insert(ratebar_toolitem, -1) |
278 |
|
279 |
voicebar.show_all() |
280 |
return voicebar |
281 |
|
282 |
def pitch_adjusted_cb(self, get, data=None): |
283 |
self.face.status.pitch = get.value |
284 |
self.face.say_notification(_("pitch adjusted")) |
285 |
|
286 |
def rate_adjusted_cb(self, get, data=None): |
287 |
self.face.status.rate = get.value |
288 |
self.face.say_notification(_("rate adjusted")) |
289 |
|
290 |
def make_face_bar(self): |
291 |
facebar = gtk.Toolbar() |
292 |
|
293 |
self.mouth_shape_combo = ComboBox() |
294 |
self.mouth_shape_combo.append_item(mouth.Mouth, _("Simple")) |
295 |
self.mouth_shape_combo.append_item(waveform_mouth.WaveformMouth, _("Waveform")) |
296 |
self.mouth_shape_combo.append_item(fft_mouth.FFTMouth, _("Frequency")) |
297 |
self.mouth_shape_combo.set_active(0) |
298 |
|
299 |
mouth_shape_toolitem = ToolWidget( |
300 |
widget=self.mouth_shape_combo, |
301 |
label_text=_('Mouth:')) |
302 |
facebar.insert(mouth_shape_toolitem, -1) |
303 |
|
304 |
self.eye_shape_combo = ComboBox() |
305 |
self.eye_shape_combo.append_item(eye.Eye, _("Round")) |
306 |
self.eye_shape_combo.append_item(glasses.Glasses, _("Glasses")) |
307 |
self.eye_shape_combo.set_active(0) |
308 |
|
309 |
eye_shape_toolitem = ToolWidget( |
310 |
widget=self.eye_shape_combo, |
311 |
label_text=_('Eyes:')) |
312 |
facebar.insert(eye_shape_toolitem, -1) |
313 |
|
314 |
self.numeyesadj = gtk.Adjustment(2, 1, 5, 1, 1, 0) |
315 |
numeyesbar = gtk.HScale(self.numeyesadj) |
316 |
numeyesbar.set_draw_value(False) |
317 |
numeyesbar.set_update_policy(gtk.UPDATE_DISCONTINUOUS) |
318 |
numeyesbar.set_size_request(240, 15) |
319 |
|
320 |
numeyesbar_toolitem = ToolWidget( |
321 |
widget=numeyesbar, |
322 |
label_text=_('Eyes number:')) |
323 |
facebar.insert(numeyesbar_toolitem, -1) |
324 |
|
325 |
facebar.show_all() |
326 |
return facebar |
327 |
|
328 |
def mouth_changed_cb(self, combo, quiet): |
329 |
self.face.status.mouth = combo.props.value |
330 |
self._update_face() |
331 |
|
332 |
# this SegFaults: self.face.say(combo.get_active_text()) |
333 |
if not quiet: |
334 |
self.face.say_notification(_("mouth changed")) |
335 |
|
336 |
def eyes_changed_cb(self, ignored, quiet): |
337 |
if self.numeyesadj is None: |
338 |
return |
339 |
|
340 |
self.face.status.eyes = [self.eye_shape_combo.props.value] \ |
341 |
* int(self.numeyesadj.value) |
342 |
self._update_face() |
343 |
|
344 |
# this SegFaults: self.face.say(self.eye_shape_combo.get_active_text()) |
345 |
if not quiet: |
346 |
self.face.say_notification(_("eyes changed")) |
347 |
|
348 |
def _update_face(self): |
349 |
self.face.update() |
350 |
self.chat.update(self.face.status) |
351 |
|
352 |
def _combo_changed_cb(self, combo): |
353 |
# when a new item is chosen, make sure the text is selected |
354 |
if not self.entry.is_focus(): |
355 |
self.entry.grab_focus() |
356 |
self.entry.select_region(0, -1) |
357 |
|
358 |
def _entry_key_press_cb(self, combo, event): |
359 |
# make the up/down arrows navigate through our history |
360 |
keyname = gtk.gdk.keyval_name(event.keyval) |
361 |
if keyname == "Up": |
362 |
index = self.entrycombo.get_active() |
363 |
if index>0: |
364 |
index-=1 |
365 |
self.entrycombo.set_active(index) |
366 |
self.entry.select_region(0,-1) |
367 |
return True |
368 |
elif keyname == "Down": |
369 |
index = self.entrycombo.get_active() |
370 |
if index<len(self.entrycombo.get_model())-1: |
371 |
index+=1 |
372 |
self.entrycombo.set_active(index) |
373 |
self.entry.select_region(0, -1) |
374 |
return True |
375 |
return False |
376 |
|
377 |
def _entry_activate_cb(self, entry): |
378 |
# the user pressed Return, say the text and clear it out |
379 |
text = entry.props.text |
380 |
if text: |
381 |
self.face.look_ahead() |
382 |
|
383 |
# speak the text |
384 |
if self._mode == MODE_BOT: |
385 |
self.face.say( |
386 |
brain.respond(self.voices.props.value, text)) |
387 |
else: |
388 |
self.face.say(text) |
389 |
|
390 |
# add this text to our history unless it is the same as the last item |
391 |
history = self.entrycombo.get_model() |
392 |
if len(history)==0 or history[-1][0] != text: |
393 |
self.entrycombo.append_text(text) |
394 |
# don't let the history get too big |
395 |
while len(history)>20: |
396 |
self.entrycombo.remove_text(0) |
397 |
# select the new item |
398 |
self.entrycombo.set_active(len(history)-1) |
399 |
# select the whole text |
400 |
entry.select_region(0, -1) |
401 |
|
402 |
def _activeCb(self, widget, pspec): |
403 |
# only generate sound when this activity is active |
404 |
if not self.props.active: |
405 |
self.face.shut_up() |
406 |
self.chat.shut_up() |
407 |
|
408 |
def _set_voice(self, new_voice): |
409 |
try: |
410 |
self.voices.handler_block_by_func(self.__changed_voices_cb) |
411 |
self.voices.select(new_voice) |
412 |
self.face.status.voice = new_voice |
413 |
finally: |
414 |
self.voices.handler_unblock_by_func(self.__changed_voices_cb) |
415 |
|
416 |
def __toggled_mode_type_cb(self, button, voices_model): |
417 |
if not button.props.active: |
418 |
return |
419 |
|
420 |
self._mode = MODE_TYPE |
421 |
self.chat.shut_up() |
422 |
self.face.shut_up() |
423 |
self.notebook.set_current_page(0) |
424 |
|
425 |
old_voice = self.voices.props.value |
426 |
self.voices.set_model(voices_model) |
427 |
self._set_voice(old_voice) |
428 |
|
429 |
def __toggled_mode_robot_cb(self, button, voices_model): |
430 |
if not button.props.active: |
431 |
return |
432 |
|
433 |
self._mode = MODE_BOT |
434 |
self.chat.shut_up() |
435 |
self.face.shut_up() |
436 |
self.notebook.set_current_page(0) |
437 |
|
438 |
old_voice = self.voices.props.value |
439 |
self.voices.set_model(voices_model) |
440 |
|
441 |
new_voice = [i[0] for i in voices_model |
442 |
if i[0].short_name == old_voice.short_name] |
443 |
if not new_voice: |
444 |
new_voice = brain.get_default_voice() |
445 |
sorry = _("Sorry, I can't speak %s, let's talk %s instead.") % \ |
446 |
(old_voice.friendlyname, new_voice.friendlyname) |
447 |
else: |
448 |
new_voice = new_voice[0] |
449 |
sorry = None |
450 |
|
451 |
self._set_voice(new_voice) |
452 |
|
453 |
if not brain.load(self, self.voices.props.value, sorry): |
454 |
if sorry: |
455 |
self.face.say_notification(sorry) |
456 |
|
457 |
def __toggled_mode_chat_cb(self, button, voices_model): |
458 |
if not button.props.active: |
459 |
return |
460 |
|
461 |
is_first_session = not self.chat.me.flags() & gtk.MAPPED |
462 |
|
463 |
self._mode = MODE_CHAT |
464 |
self.face.shut_up() |
465 |
self.notebook.set_current_page(1) |
466 |
|
467 |
old_voice = self.voices.props.value |
468 |
self.voices.set_model(voices_model) |
469 |
self._set_voice(old_voice) |
470 |
|
471 |
if is_first_session: |
472 |
self.chat.me.say_notification( |
473 |
_("You are in off-line mode, share and invite someone.")) |
474 |
|
475 |
def __changed_voices_cb(self, combo): |
476 |
voice = combo.props.value |
477 |
self.face.set_voice(voice) |
478 |
if self._mode == MODE_BOT: |
479 |
brain.load(self, voice) |
480 |
|
481 |
|
482 |
# activate gtk threads when this module loads |
483 |
gtk.gdk.threads_init() |