def __init__(self, dirpath, unix_sig_notify_read_socket=None, stand_alone=None): if stand_alone is None: stand_alone = DEFAULT_PLAY_LOCATION == PlayLocation.LOCALLY originalStandAlone = stand_alone self.unix_sig_notify_read_socket = unix_sig_notify_read_socket self.stand_alone = stand_alone self.gui = None self.soundPlayer = None self.textToSpeechPlayer = None self.rosInitialized = False self.speechReplayDemons = [] self.soundReplayDemons = [] self.sound_file_names = [] self.roboComm = None localInit = False robotInit = False # Remember original default play location, so that # we can warn user of the import error condition further # down, when the GUI is up: DEFAULT_PLAY_LOCATION_ORIG = DEFAULT_PLAY_LOCATION # If this is the first time SpeakEasy is started on this machine, # by this user, then create a .speakeasy subdirectory under the # user's HOME, and copy all sound effects and buttonPrograms # into that subdirectory: self.initConfigDir() if self.stand_alone or not ROS_IMPORT_OK: localInit = self.initLocalOperation() else: # Robot operation robotInit = self.initROSOperation() self.gui = SpeakEasyGUI(stand_alone=self.stand_alone, sound_effect_labels=self.sound_file_names) self.gui.setWindowTitle("SpeakEasy (V" + SpeakEasyController.VERSION + ")") self.dialogService = DialogService(self.gui) # Handler that makes program button temporarily # look different to indicate entry into program mode: self.gui.hideButtonSignal.connect(alternateLookHandler) # Handler that makes program button look normal: self.gui.showButtonSignal.connect(standardLookHandler) # Now that we have the GUI up, we can warn user # if ROS couldn't be imported, the ROS node wasn't running, # or no SpeakEasy node was available, yet this app was # set to control a Ros node (rather than running locally): if originalStandAlone == PlayLocation.ROBOT and not robotInit: self.dialogService.showErrorMsg( "Application was set to control sound on robot, but: %s. Switching to local operation." % str(self.rosInitException) ) if self.stand_alone: self.gui.setWhereToPlay(PlayLocation.LOCALLY) else: self.gui.setWhereToPlay(PlayLocation.ROBOT) self.dirpath = dirpath # No speech buttons programmed yet: self.programs = {} self.currentButtonSetFile = os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml") # Accept SIGUSR1 and SIGUSR2 from other processes # to initiate PASTE and CLEAR operations, of the # text area, respectively. Done via a socket from # outside the application, which we connect to a QSocketNofifier # (see __main__ below): self.unixSigNotifier = QSocketNotifier(self.unix_sig_notify_read_socket.fileno(), QSocketNotifier.Read) self.connectWidgetsToActions() self.installDefaultSpeechSet() # Let other processes know our pid: self.publishPID()
class SpeakEasyController(object): """ Control logic behind the speakeasy GUI. Available voices: 1. Festival: Usually voice_kal_diphone (male) on Ubuntu installations 2. Cepstral: Depends on your installation. Voices are individually licensed. """ VERSION = "0.1.4" PID_PUBLICATION_FILE = "/tmp/speakeasyPID" PROJECT_ROOT = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../..") CONFIG_PATH = os.path.join(os.getenv("HOME"), ".speakeasy") # Mapping from sound button names ('SOUND_1', 'SOUND_2', etc) to sound filename (just basename): soundPaths = {} # Mapping sound file basenames to their full file names: soundPathsFull = {} # Constant for repeating play of sound/music/voice forever: FOREVER = -1 # Unix signals for use with clearing text remotely, and with # pasting and speech-triggering from remote: REMOTE_CLEAR_TEXT_SIG = signal.SIGUSR1 REMOTE_PASTE_AND_SPEAK_SIG = signal.SIGUSR2 def __init__(self, dirpath, unix_sig_notify_read_socket=None, stand_alone=None): if stand_alone is None: stand_alone = DEFAULT_PLAY_LOCATION == PlayLocation.LOCALLY originalStandAlone = stand_alone self.unix_sig_notify_read_socket = unix_sig_notify_read_socket self.stand_alone = stand_alone self.gui = None self.soundPlayer = None self.textToSpeechPlayer = None self.rosInitialized = False self.speechReplayDemons = [] self.soundReplayDemons = [] self.sound_file_names = [] self.roboComm = None localInit = False robotInit = False # Remember original default play location, so that # we can warn user of the import error condition further # down, when the GUI is up: DEFAULT_PLAY_LOCATION_ORIG = DEFAULT_PLAY_LOCATION # If this is the first time SpeakEasy is started on this machine, # by this user, then create a .speakeasy subdirectory under the # user's HOME, and copy all sound effects and buttonPrograms # into that subdirectory: self.initConfigDir() if self.stand_alone or not ROS_IMPORT_OK: localInit = self.initLocalOperation() else: # Robot operation robotInit = self.initROSOperation() self.gui = SpeakEasyGUI(stand_alone=self.stand_alone, sound_effect_labels=self.sound_file_names) self.gui.setWindowTitle("SpeakEasy (V" + SpeakEasyController.VERSION + ")") self.dialogService = DialogService(self.gui) # Handler that makes program button temporarily # look different to indicate entry into program mode: self.gui.hideButtonSignal.connect(alternateLookHandler) # Handler that makes program button look normal: self.gui.showButtonSignal.connect(standardLookHandler) # Now that we have the GUI up, we can warn user # if ROS couldn't be imported, the ROS node wasn't running, # or no SpeakEasy node was available, yet this app was # set to control a Ros node (rather than running locally): if originalStandAlone == PlayLocation.ROBOT and not robotInit: self.dialogService.showErrorMsg( "Application was set to control sound on robot, but: %s. Switching to local operation." % str(self.rosInitException) ) if self.stand_alone: self.gui.setWhereToPlay(PlayLocation.LOCALLY) else: self.gui.setWhereToPlay(PlayLocation.ROBOT) self.dirpath = dirpath # No speech buttons programmed yet: self.programs = {} self.currentButtonSetFile = os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml") # Accept SIGUSR1 and SIGUSR2 from other processes # to initiate PASTE and CLEAR operations, of the # text area, respectively. Done via a socket from # outside the application, which we connect to a QSocketNofifier # (see __main__ below): self.unixSigNotifier = QSocketNotifier(self.unix_sig_notify_read_socket.fileno(), QSocketNotifier.Read) self.connectWidgetsToActions() self.installDefaultSpeechSet() # Let other processes know our pid: self.publishPID() def initConfigDir(self): SpeakEasyController.SOUND_DIR = os.path.join(SpeakEasyController.CONFIG_PATH, "sounds") origSpeechSetDir = ButtonSavior.SPEECH_SET_DIR ButtonSavior.SPEECH_SET_DIR = os.path.join( SpeakEasyController.CONFIG_PATH, os.path.basename(ButtonSavior.SPEECH_SET_DIR) ) # Config dir already exists? if not os.path.isdir(SpeakEasyController.CONFIG_PATH): # No. ==> First time start of SpeakEasy for this user on this machine. # Copy the default sounds and button programs to the config dir # ($HOME/.speakeasy): shutil.copytree(os.path.join(SpeakEasyController.PROJECT_ROOT, "sounds"), SpeakEasyController.SOUND_DIR) # Button programs directory: shutil.copytree(origSpeechSetDir, ButtonSavior.SPEECH_SET_DIR) def findButtonFileOriginalForDefault(self): """ Attempt to find buttonProgram file that is the same as the default.xml file, except for the title. Doesn't work; ran out of time. """ buttonSetDefaultFile = os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml") with open(buttonSetDefaultFile, "r") as fd: defaultSet = fd.readlines() defaultSetInfoOnly = defaultSet[2:] fileAndDirsList = os.listdir(ButtonSavior.SPEECH_SET_DIR) for fileName in fileAndDirsList: if fileName == "default.xml": continue fullFileName = os.path.join(ButtonSavior.SPEECH_SET_DIR, fileName) with open(fullFileName, "r") as fd: xmlContent = fd.readlines() xmlSetInfoOnly = xmlContent[2:] if xmlSetInfoOnly == defaultSetInfoOnly: return fullFileName return None # ---------------------------------- # shutdown # -------------- def shutdown(self): """ Delete the PID pub file. Not crucial, but nice to let other processes know that SpeakEasy is no longer running. """ try: self.gui.speechControls.shutdown() os.remove(SpeakEasyController.PID_PUBLICATION_FILE) except: pass # ---------------------------------- # initLocalOperation # -------------- def initLocalOperation(self): """ Initialize for playing sound and text-to-speech locally. OK to call multiple times. Initializes self.sound_file_names to a list of sound file names for use with SoundPlayer instance. @return: True if initialization succeeded, else False. @rtype: boolean """ if self.soundPlayer is None: self.soundPlayer = SoundPlayer() if self.textToSpeechPlayer is None: self.textToSpeechPlayer = TextToSpeechProvider() self.sound_file_names = self.getAvailableSoundEffectFileNames(stand_alone=True) self.stand_alone = True return True # ---------------------------------- # initROSOperation # -------------- def initROSOperation(self): """ Try to initialize operation through ROS messages to a SpeakEasy ROS node. If that init fails, revert to local operation. @return: True if ROS operation init succeeded. Else, if local ops was initiated instead, return False. @rtype: bool """ # Try to initialize ROS. If that does not work, instantiation # raises NotImplementedError, or IOError: try: self.roboComm = RoboComm() self.sound_file_names = self.roboComm.getSoundEffectNames() self.stand_alone = False return True except Exception as rosInitFailure: self.rosInitException = rosInitFailure # Robot init didn't work, fall back to local op: self.initLocalOperation() return False # ---------------------------------- # connectWidgetsToActions # -------------- def connectWidgetsToActions(self): self.gui.speechInputFld for recorderButton in self.gui.recorderButtonDict.values(): recorderButton.clicked.connect(partial(self.actionRecorderButtons, recorderButton)) self.connectProgramButtonsToActions() for soundButton in self.gui.soundButtonDict.values(): soundButton.clicked.connect(partial(self.actionSoundButtons, soundButton)) newSpeechSetButton = self.gui.speechSetButtonDict[SpeakEasyGUI.interactionWidgets["NEW_SPEECH_SET"]] newSpeechSetButton.clicked.connect(self.actionNewSpeechSet) pickSpeechSetButton = self.gui.speechSetButtonDict[SpeakEasyGUI.interactionWidgets["PICK_SPEECH_SET"]] pickSpeechSetButton.clicked.connect(self.actionPickSpeechSet) # Location where to play: Locally, or at the Robot: for radioButton in self.gui.playLocalityRadioButtonsDict.values(): if radioButton.text() == "Play at robot": radioButton.clicked.connect(partial(self.actionWhereToPlayRadioButton, PlayLocation.ROBOT)) else: radioButton.clicked.connect(partial(self.actionWhereToPlayRadioButton, PlayLocation.LOCALLY)) self.gui.replayPeriodSpinBox.valueChanged.connect(self.actionRepeatPeriodChanged) pasteButton = self.gui.convenienceButtonDict[SpeakEasyGUI.interactionWidgets["PASTE"]] pasteButton.clicked.connect(self.actionPaste) clearButton = self.gui.convenienceButtonDict[SpeakEasyGUI.interactionWidgets["CLEAR"]] clearButton.clicked.connect(self.actionClear) speechControlButton = self.gui.convenienceButtonDict[SpeakEasyGUI.interactionWidgets["SPEECH_MODULATION"]] speechControlButton.clicked.connect(self.actionSpeechControls) # Remote control of clearing text field, and speaking what's in the # text field from other applications. Handled via Unix signals SIGUSR1 # and SIGUSR2. These are caught in handleOS_SIGUSR1_2() in __main__. The # handler writes the respective signal number to the socket, which triggerse # a socket notifier. We connect that notifier to a handler: self.unixSigNotifier.activated.connect(self.actionUnixSigReceived) @Slot(int) def actionUnixSigReceived(self, socket): # Read the signal number (32 is the buff size): (sigNumStr, socketAddr) = self.unix_sig_notify_read_socket.recvfrom(32) sigNumStr = sigNumStr.strip() if sigNumStr == str(SpeakEasyController.REMOTE_CLEAR_TEXT_SIG): self.actionClear() elif sigNumStr == str(SpeakEasyController.REMOTE_PASTE_AND_SPEAK_SIG): self.actionPaste() playButton = self.gui.recorderButtonDict[self.gui.interactionWidgets["PLAY_TEXT"]] self.actionRecorderButtons(playButton) def connectProgramButtonsToActions(self): for programButton in self.gui.programButtonDict.values(): programButton.pressed.connect(partial(self.actionProgramButtons, programButton)) programButton.released.connect(partial(self.actionProgramButtonRelease, programButton)) # Each program button gets a context menu. # Context menu entry to copy programmed text to the text area: copyToTextAreaAction = QAction("Copy to text area", programButton) copyToTextAreaAction.triggered.connect( partial(self.programButtonContextMenuCopyToTextAreaAction, programButton) ) programButton.addAction(copyToTextAreaAction) broadcastSavedProgramAction = QAction("Broadcast Program", programButton) broadcastSavedProgramAction.triggered.connect( partial(self.programButtonContextMenuBroadcastSavedProgramAction, programButton) ) programButton.addAction(broadcastSavedProgramAction) programButton.setContextMenuPolicy(Qt.ActionsContextMenu) # ---------------------------------- # getAvailableSoundEffectFileNames # -------------- def getAvailableSoundEffectFileNames(self, stand_alone=None): """ Determine all the available sound effect files. If this process operates stand-alone, the local '../../sounds' subdirectory is searched. Else, in a ROS environment, the available sound effect file names are obtained from the 'speech_capabilities_inquiry' service call. @param stand_alone: False if referenced sounds are to be from the ROS environment. @type stand_alone: boolean @return: array of sound file basenames without extensions. E.g.: [rooster, birds, elephant] @rtype: [string] """ if stand_alone is None: stand_alone = self.stand_alone # Standalone files are local to this process: if stand_alone: return self.getAvailableLocalSoundEffectFileNames() # Get sound effect names from SpeakEasy ROS node: return self.roboComm.getSoundEffectNames() # ---------------------------------- # getAvailableLocalSoundEffectFileNames # -------------- def getAvailableLocalSoundEffectFileNames(self): # scriptDir = os.path.dirname(os.path.realpath(__file__)); # soundDir = os.path.join(scriptDir, "../../sounds"); if not os.path.exists(SpeakEasyController.SOUND_DIR): raise ValueError("No sound files found.") fileAndDirsList = os.listdir(SpeakEasyController.SOUND_DIR) fileList = [] # Grab all usable sound file names: for fileName in fileAndDirsList: fileExtension = SpeakeasyUtils.fileExtension(fileName) if (fileExtension == "wav") or (fileExtension == "ogg"): fileList.append(fileName) sound_file_basenames = [] for (i, full_file_name) in enumerate(fileList): baseName = os.path.basename(full_file_name) SpeakEasyController.soundPaths["SOUND_" + str(i)] = full_file_name # Chop extension off the basename (e.g. rooster.wav --> rooster): sound_file_basenames.append(os.path.splitext(os.path.basename(full_file_name))[0]) # Map basename (e.g. 'rooster.wav') to its full file name. self.soundPathsFull[baseName] = os.path.join(SpeakEasyController.SOUND_DIR, full_file_name) return sound_file_basenames # ---------------------------------- # sayText # -------------- def sayText(self, text, voice, ttsEngine="festival", sayOnce=True, stand_alone=None): """ Send message to SpeakEasy service to say text, with the given voice, using the given text-to-speech engine. </p> If the voice parameter is the Festival voice 'Male' it is a special case in that it refers to the Festival engine's "voice_kal_diphone". We convert this. @param text: Text to be uttered by the tts engine @type string @param voice: Name of speaking voice to be used. @type string @param ttsEngine: Name of tts engine to use (e.g. "festival" (the default), "cepstral" @type string @param sayOnce: Whether repeat the utterance over and over, or just say it once. @type bool """ if stand_alone is None: stand_alone = self.stand_alone if ttsEngine == "festival" and voice == "Male": voice = "voice_kal_diphone" # Repeat over and over? Or say once? if stand_alone: if sayOnce: try: self.textToSpeechPlayer.say(text, voice, ttsEngine) except ValueError: self.dialogService.showErrorMsg( "Voice '" + str(voice) + "' is not supported by the text-to-speech engine '" + str(ttsEngine) + "'." ) else: self.speechReplayDemons.append( SpeakEasyController.SpeechReplayDemon( text, voice, ttsEngine, self.gui.getPlayRepeatPeriod(), self.textToSpeechPlayer ) ) self.speechReplayDemons[-1].start() else: if sayOnce: self.roboComm.say(text, voice=voice, ttsEngine=ttsEngine) else: self.roboComm.say( text, voice=voice, ttsEngine=ttsEngine, numRepeats=SpeakEasyController.FOREVER, repeatPeriod=self.gui.getPlayRepeatPeriod(), ) return # ---------------------------------- # convertRawTextToSSML # -------------- def convertRawTextToSSML(self, rawText): """ Given a string with SpeakEasy speech modulation markup, convert the string to W3C SSML marked-up text, and return a new string. Example: 'This is [P90my] test' --> 'This is <prosody pitch='+90%'>my</prosody> test'. Note: Only the Cepstral engine currently handles SSML. @param theStr: string to convert @type theStr: String """ try: ssmlText = MarkupManagement.convertStringToSSML(rawText) except ValueError as e: self.dialogService.showErrorMsg(` e `) return ssmlText # ---------------------------------- # actionRecorderButtons # -------------- def actionRecorderButtons(self, buttonObj): """ Handler for one of the recorder buttons pushed: Play Text, or Stop. @param buttonObj: The button object that was pushed. @type buttonObj: QPushButton """ # Play button pushed? buttonKey = self.gui.interactionWidgets["PLAY_TEXT"] if buttonObj == self.gui.recorderButtonDict[buttonKey]: # If nothing in text input field, error msg, and done: if self.gui.speechInputFld.isEmpty(): self.dialogService.showErrorMsg("Nothing to play; enter text in the text field.") return # Got text in input fld. Which of the voices is checked? voice = self.gui.activeVoice() if voice == "voice_kal_diphone": ttsEngine = "festival" else: ttsEngine = "cepstral" rawText = self.gui.speechInputFld.getText() if ttsEngine == "cepstral": # Convert any speech modulation markup to official W3C SSML markup: ssmlText = self.convertRawTextToSSML(rawText) else: ssmlText = rawText self.sayText(ssmlText, voice, ttsEngine, self.gui.playOnceChecked()) return # Stop button pushed? buttonKey = self.gui.interactionWidgets["STOP"] if buttonObj == self.gui.recorderButtonDict[buttonKey]: self.stopAll() # ---------------------------------- # stopAll # -------------- def stopAll(self): if self.stand_alone: if len(self.speechReplayDemons) > 0: for speechDemon in self.speechReplayDemons: speechDemon.stop() self.speechReplayDemons = [] self.textToSpeechPlayer.stop() if len(self.soundReplayDemons) > 0: for soundDemon in self.soundReplayDemons: soundDemon.stop() self.soundReplayDemons = [] self.soundPlayer.stop() self.textToSpeechPlayer.stop() else: # Robot op self.roboComm.stopSaying() self.roboComm.stopSound() return # ---------------------------------- # actionWhereToPlayRadioButton # -------------- def actionWhereToPlayRadioButton(self, playLocation): if playLocation == PlayLocation.LOCALLY: self.stopAll() self.initLocalOperation() elif playLocation == PlayLocation.ROBOT: self.stopAll() success = self.initROSOperation() if not success: self.dialogService.showErrorMsg("Could not communicate with robot. Is the rosmaster node running?") # Switch radio button selection back to 'Play Locally': self.gui.setWhereToPlay(PlayLocation.LOCALLY) # ---------------------------------- # programButtonContextMenuCopyToTextAreaAction # -------------- def programButtonContextMenuCopyToTextAreaAction(self, buttonObj): program = None try: program = self.programs[buttonObj] except KeyError: self.dialogService.showErrorMsg( "This button does not contain a program. Press-and-hold for " + str(int(SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME)) + " seconds to program." ) return ttsEngine = program.ttsEngine rawText = program.getText() if len(rawText) == 0: self.dialogService.showErrorMsg( "This button does not contain a program. Press-and-hold for " + str(int(SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME)) + " seconds to program." ) return if ttsEngine == "cepstral": # Convert any speech modulation markup to official W3C SSML markup: ssmlText = self.convertRawTextToSSML(rawText) else: ssmlText = rawText textArea = self.gui.speechInputFld textArea.append(ssmlText) # ---------------------------------- # programButtonBroadcastSavedProgramAction # -------------- def programButtonContextMenuBroadcastSavedProgramAction(self, buttonObj): program = None try: program = self.programs[buttonObj] except KeyError: self.dialogService.showErrorMsg( "This button does not contain a program. Press-and-hold for " + str(int(SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME)) + " seconds to program." ) return ttsEngine = program.getTtsEngine() rawText = program.getText() if len(rawText) == 0: self.dialogService.showErrorMsg( "This button does not contain a program. Press-and-hold for " + str(int(SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME)) + " seconds to program." ) return if ttsEngine == "cepstral": # Convert any speech modulation markup to official W3C SSML markup: ssmlText = self.convertRawTextToSSML(rawText) else: ssmlText = rawText voice = program.getVoice() try: RoboComm.broadcastButtonProgram(ssmlText, voice, ttsEngine) except NotImplementedError: self.dialogService.showErrorMsg("Broadcasting button programs requires the ROS master to be running.") # ---------------------------------- # actionProgramButtons # -------------- def actionProgramButtons(self, buttonObj): # Record press-down time: self.programButtonPushedTime = time.time() # fractional seconds till beginning of epoch # Schedule the button to blink when the programming mode hold delay is over: self.buttonBlinkTimer = Timer( SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME, partial(self.gui.blinkButton, buttonObj, False) ) self.buttonBlinkTimer.start() # ---------------------------------- # actionProgramButtonRelease # -------------- def actionProgramButtonRelease(self, buttonObj): timeNow = time.time() # fractional seconds till beginning of epoch self.buttonBlinkTimer.cancel() # Sometimes the down press seems to get missed, and then # self.programButtonPushedTime is None. Likely that happens # when buttons are clicked quickly: if self.programButtonPushedTime is None: self.programButtonPushedTime = timeNow holdTime = timeNow - self.programButtonPushedTime # Button no longer held down: self.programButtonPushedTime = None # Held long enough to indicate intention to program?: if holdTime >= SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME: self.programOneButton(buttonObj) else: self.playProgram(buttonObj) # ---------------------------------- # programOneButton # -------------- def programOneButton(self, buttonObj): if self.gui.speechInputFld.isEmpty(): self.dialogService.showErrorMsg("You need to enter text in the input panel to program a button.") return if os.path.basename(self.currentButtonSetFile) == "default.xml": self.dialogService.showInfoMessage( "Before (re)programming a button, please choose an existing button set via the 'Pick different speech set' button, or create a new set via the 'Save speech set' button." ) return newButtonLabel = self.gui.getNewButtonLabel() if newButtonLabel is not None: self.gui.setButtonLabel(buttonObj, newButtonLabel) textToSave = self.gui.speechInputFld.getText() if self.gui.activeVoice() == "voice_kal_diphone": ttsEngine = "festival" else: ttsEngine = "cepstral" programObj = ButtonProgram( buttonObj.text(), textToSave, self.gui.activeVoice(), ttsEngine, self.gui.playOnceChecked() ) self.programs[buttonObj] = programObj # ---------------------------------- # playProgram # -------------- def playProgram(self, buttonObj): program = None try: program = self.programs[buttonObj] except KeyError: self.dialogService.showErrorMsg( "This button does not contain a program. Press-and-hold for " + str(int(SpeakEasyGUI.PROGRAM_BUTTON_HOLD_TIME)) + " seconds to program." ) return onlyPlayOnce = program.playOnce voice = program.activeVoice ttsEngine = program.ttsEngine rawText = program.getText() if ttsEngine == "cepstral": # Convert any speech modulation markup to official W3C SSML markup: ssmlText = self.convertRawTextToSSML(rawText) else: ssmlText = rawText self.sayText(ssmlText, voice, ttsEngine, onlyPlayOnce) # ---------------------------------- # actionSoundButtons # -------------- def actionSoundButtons(self, buttonObj): soundIndx = 0 while True: soundKey = "SOUND_" + str(soundIndx) try: soundLabel = self.gui.interactionWidgets[soundKey] oneButtonObj = self.gui.soundButtonDict[soundLabel] except KeyError: raise ValueError("Unknown widget passed to actionSoundButton() method: " + str(buttonObj)) if buttonObj == oneButtonObj: # For local operation, sound effect button labels are keys # to a dict that maps to the local file names: if self.stand_alone: soundFile = SpeakEasyController.soundPaths[soundKey] else: soundFile = buttonObj.text() break else: soundIndx += 1 if self.stand_alone: originalSoundFile = soundFile try: if not os.path.exists(soundFile): try: soundFile = self.soundPathsFull[soundFile] except KeyError: self.dialogService.showErrorMsg( "Sound file %s not found. Searched %s/../../sounds." % (originalSoundFile, __file__) ) return soundInstance = self.soundPlayer.play(soundFile) if self.gui.playRepeatedlyChecked(): self.soundReplayDemons.append( SpeakEasyController.SoundReplayDemon( soundInstance, self.gui.getPlayRepeatPeriod(), self.soundPlayer ) ) self.soundReplayDemons[-1].start() except IOError as e: self.dialogService.showErrorMsg(str(e)) return else: # Robot operation if self.gui.playRepeatedlyChecked(): self.roboComm.playSound( soundFile, numRepeats=SpeakEasyController.FOREVER, repeatPeriod=self.gui.getPlayRepeatPeriod() ) else: self.roboComm.playSound(soundFile) # ------------------------- Changing and Adding Button Programs -------------- # ---------------------------------- # actionNewSpeechSet # -------------- def actionNewSpeechSet(self): # Get an iterator over all the current program# button UI widgets: programButtonIt = self.gui.programButtonIterator() # Get an array of ButtonProgram objects that are associated # with those buttons: buttonProgramArray = [] while True: try: buttonObj = programButtonIt.next() buttonLabel = buttonObj.text() try: buttonProgramArray.append(self.programs[buttonObj]) except KeyError: # Button was not programmed. Create an empty ButtonProgram: buttonProgramArray.append(ButtonProgram(buttonLabel, "", "Male", "festival")) except StopIteration: break # Save this array of programs as XML: makeNewFile = self.dialogService.newButtonSetOrUpdateCurrent() if makeNewFile == DialogService.ButtonSaveResult.CANCEL: return if makeNewFile == DialogService.ButtonSaveResult.NEW_SET: fileName = self.getNewSpeechSetName() ButtonSavior.saveToFile(buttonProgramArray, fileName, title=os.path.basename(fileName)) self.currentButtonSetFile = fileName buttonSetNum = self.getSpeechSetFromSpeechFileName(fileName) self.dialogService.showInfoMessage("New speech set %d created." % buttonSetNum) elif makeNewFile == DialogService.ButtonSaveResult.UPDATE_CURRENT: fileName = self.currentButtonSetFile ButtonSavior.saveToFile(buttonProgramArray, fileName, title=os.path.basename(fileName)) if os.path.basename(fileName) != "default.xml": buttonSetNum = self.getSpeechSetFromSpeechFileName(self.currentButtonSetFile) self.dialogService.showInfoMessage("Saved to speech button set %d." % buttonSetNum) else: self.dialogService.showInfoMessage("Saved to speech button set 'default.xml'") if os.path.basename(fileName) != "default.xml": try: shutil.copy(fileName, os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml")) except: rospy.logerr("Could not copy new program XML file to default.xml.") # ---------------------------------- # actionPickSpeechSet # -------------- def actionPickSpeechSet(self): # Build an array of ButtonProgram instances for each # of the XML files in the button set directory. Collect # these arrays in buttonProgramArray: xmlFileNames = self.getAllSpeechSetXMLFileNames() if xmlFileNames is None: self.dialogService.showErrorMsg("No additional button sets are stored on your disk.") return None # Fill the following array with arrays of ButtonProgram: buttonProgramArrays = [] # Associate each ButtonProgram array with the file name # from which it was built: buttonProgramSetFiles = {} for xmlFileName in xmlFileNames: if xmlFileName == "default.xml": continue try: (buttonSettingTitle, buttonProgram) = ButtonSavior.retrieveFromFile(xmlFileName, ButtonProgram) buttonProgramArrays.append(buttonProgram) buttonSet = ButtonSet(buttonProgram, xmlFileName) buttonProgramSetFiles[buttonSet] = xmlFileName except ValueError as e: # Bad XML: rospy.logerr(` e `) return buttonSetSelector = ButtonSetPopupSelector(iter(buttonProgramArrays)) buttonSetSelected = buttonSetSelector.exec_() if buttonSetSelected == -1: self.dialogService.showErrorMsg("No button sets have been defined yet.") return if buttonSetSelected == 0: # User cancelled: return # Get the selected ButtonProgram array: buttonPrograms = buttonSetSelector.getCurrentlyShowingSet() self.replaceProgramButtons(buttonPrograms) # Update the disk file: # ***buttonSetBaseFilename = buttonProgramSetFiles[buttonPrograms]; buttonSetBaseFilename = None for buttonSet in buttonProgramSetFiles.keys(): if buttonSet.getButtonProgram() == buttonPrograms: buttonSetBaseFilename = buttonSet.getXMLFileName() break if buttonSetBaseFilename is not None: self.currentButtonSetFile = os.path.join(ButtonSavior.SPEECH_SET_DIR, buttonSetBaseFilename) # Copy this new XML file into default.xml, so that it will be # loaded next time the application starts: ButtonSavior.saveToFile( buttonPrograms, os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml"), title="default.xml" ) # ---------------------------------- # actionRepeatPeriodChanged # -------------- def actionRepeatPeriodChanged(self): # If the repeat period is changed on its spinbox, # automatically select 'Play repeatedly': self.gui.setPlayRepeatedlyChecked() # ---------------------------------- # actionClear # -------------- def actionClear(self): self.gui.speechInputFld.clear() # ---------------------------------- # actionPaste # -------------- def actionPaste(self): # Also called by handleOS_SIGUSR1_2 textArea = self.gui.speechInputFld currCursor = textArea.textCursor() currCursor.insertText(QApplication.clipboard().text()) # ---------------------------------- # actionSpeechControls # -------------- def actionSpeechControls(self): """ Raise the voice modulation control panel. """ self.gui.speechControls.show() self.gui.speechControls.raise_() # ---------------------------------- # installDefaultSpeechSet # -------------- def installDefaultSpeechSet(self): defaultPath = os.path.join(ButtonSavior.SPEECH_SET_DIR, "default.xml") if not os.path.exists(defaultPath): return (buttonSetTitle, buttonPrograms) = ButtonSavior.retrieveFromFile(defaultPath, ButtonProgram) self.replaceProgramButtons(buttonPrograms) # ---------------------------------- # replaceProgramButtons # -------------- def replaceProgramButtons(self, buttonProgramArray): self.gui.replaceProgramButtons(buttonProgramArray) self.connectProgramButtonsToActions() # Update the button object --> ButtonProgram instance mapping: self.programs = {} buttonObjIt = self.gui.programButtonIterator() for buttonProgram in buttonProgramArray: try: self.programs[buttonObjIt.next()] = buttonProgram except StopIteration: # Should not happen: raise ValueError("Fewer buttons than ButtonProgram instances.") # ---------------------------------- # getAllSpeechSetXMLFileNames # -------------- def getAllSpeechSetXMLFileNames(self): if not os.path.exists(ButtonSavior.SPEECH_SET_DIR): return None xmlFileNames = [] for fileName in os.listdir(ButtonSavior.SPEECH_SET_DIR): if fileName.endswith(".xml") or fileName.endswith(".XML"): xmlFileNames.append(fileName) if len(xmlFileNames) == 0: return None return xmlFileNames # ---------------------------------- # getNewSpeechSetName # -------------- def getNewSpeechSetName(self): if not os.path.exists(ButtonSavior.SPEECH_SET_DIR): os.makedirs(ButtonSavior.SPEECH_SET_DIR) suffix = 1 newFileName = "buttonProgram1.xml" fileSet = set(os.listdir(ButtonSavior.SPEECH_SET_DIR)) while newFileName in fileSet: suffix += 1 newFileName = "buttonProgram" + str(suffix) + ".xml" return os.path.join(ButtonSavior.SPEECH_SET_DIR, newFileName) # ---------------------------------- # getSpeechSetFromSpeechFileName # -------------- def getSpeechSetFromSpeechFileName(self, filePath): """ Given a file path to a button set, return the button set's number. We assume that the path is well formed, and all button sets are named buttonProgramnnn.xml. If malformed path, shows error msg on screen, and returns None. @param filePath: Path to xml file. @type filePath: string @return: Number encoded in file name (i.e. nnn) @rtype: int """ # Get something like: buttonSet2.xml: fileName = os.path.basename(filePath).split(".")[0] buttonSetNum = fileName[len("buttonProgram") :] try: return int(buttonSetNum) except ValueError: self.dialogService.showErrorMsg("Bad file path to button sets: %s" % filePath) return None # ---------------------------------- # publishPID # -------------- def publishPID(self): with open(SpeakEasyController.PID_PUBLICATION_FILE, "w") as fd: fd.write(str(os.getpid())) # ---------------------------------- # handleOS_SIGUSR1_2 # -------------- def handleOS_SIGUSR1_2(self, signum, stack): if signum == signal.SIGUSR1: self.actionPaste elif signum == signal.SIGUSR2: self.actionClear() # -------------------------------------------- Replay Demon ------------------------------- # Only used for local operation: class ReplayDemon(threading.Thread): def __init__(self, repeatPeriod): super(SpeakEasyController.ReplayDemon, self).__init__() self.repeatPeriod = repeatPeriod self.stopped = True class SoundReplayDemon(ReplayDemon): def __init__(self, soundInstance, repeatPeriod, soundPlayer): super(SpeakEasyController.SoundReplayDemon, self).__init__(repeatPeriod) self.soundInstance = soundInstance self.soundPlayer = soundPlayer def run(self): self.stopped = False self.soundPlayer.waitForSoundDone(self.soundInstance) while not self.stopped: time.sleep(self.repeatPeriod) self.soundPlayer.play(self.soundInstance, blockTillDone=True) def stop(self): self.stopped = True self.soundPlayer.stop(self.soundInstance) class SpeechReplayDemon(ReplayDemon): def __init__(self, text, voiceName, ttsEngine, repeatPeriod, textToSpeechPlayer): super(SpeakEasyController.SpeechReplayDemon, self).__init__(repeatPeriod) self.text = text self.ttsEngine = ttsEngine self.voiceName = voiceName self.textToSpeechPlayer = textToSpeechPlayer def run(self): self.stopped = False self.textToSpeechPlayer.waitForSoundDone() while not self.stopped: time.sleep(self.repeatPeriod) try: self.textToSpeechPlayer.say(self.text, self.voiceName, self.ttsEngine, blockTillDone=True) except: # If any problem, stop this thread so that we don't keep # generating that same error: self.stop() def stop(self): self.stopped = True self.textToSpeechPlayer.stop()