diff --git a/source/nvwave.py b/source/nvwave.py index ab3b97c0664..b288a97be22 100644 --- a/source/nvwave.py +++ b/source/nvwave.py @@ -143,7 +143,9 @@ def __init__(self, channels, samplesPerSec, bitsPerSample, if buffered: #: Minimum size of the buffer before audio is played. #: However, this is ignored if an C{onDone} callback is provided to L{feed}. - self._minBufferSize = samplesPerSec * channels * (bitsPerSample / 8) / 1000 * self.MIN_BUFFER_MS + BITS_PER_BYTE = 8 + MS_PER_SEC = 1000 + self._minBufferSize = samplesPerSec * channels * (bitsPerSample / BITS_PER_BYTE) / MS_PER_SEC * self.MIN_BUFFER_MS self._buffer = "" else: self._minBufferSize = None diff --git a/source/sayAllHandler.py b/source/sayAllHandler.py index 19d9c62b535..d34b91970db 100644 --- a/source/sayAllHandler.py +++ b/source/sayAllHandler.py @@ -142,7 +142,7 @@ def nextLine(self): return # Call lineReached when we start speaking this line. # lineReached will move the cursor and trigger reading of the next line. - cb = speech.CallbackCommand(lambda: self.lineReached(bookmark, self.speakTextInfoState.copy())) + cb = speech.CallbackCommand(lambda obj=self.reader.obj, state=self.speakTextInfoState.copy(): self.lineReached(obj,bookmark, state)) spoke = speech.speakTextInfo(self.reader, unit=textInfos.UNIT_READINGCHUNK, reason=controlTypes.REASON_SAYALL, _prefixSpeechCommand=cb, useCache=self.speakTextInfoState) @@ -168,10 +168,10 @@ def nextLine(self): # The first buffered line has now started speaking. self.numBufferedLines -= 1 - def lineReached(self, bookmark, state): + def lineReached(self, obj, bookmark, state): # We've just started speaking this line, so move the cursor there. state.updateObj() - updater = self.reader.obj.makeTextInfo(bookmark) + updater = obj.makeTextInfo(bookmark) if self.cursor == CURSOR_CARET: updater.updateCaret() if self.cursor != CURSOR_CARET or config.conf["reviewCursor"]["followCaret"]: diff --git a/source/speech.py b/source/speech/__init__.py similarity index 73% rename from source/speech.py rename to source/speech/__init__.py index 2792bae1337..393ed64a5af 100755 --- a/source/speech.py +++ b/source/speech/__init__.py @@ -1,5 +1,4 @@ # -*- coding: UTF-8 -*- -#speech.py #A part of NonVisual Desktop Access (NVDA) #This file is covered by the GNU General Public License. #See the file COPYING for more details. @@ -30,6 +29,7 @@ import speechDictHandler import characterProcessing import languageHandler +from .commands import * speechMode_off=0 speechMode_beeps=1 @@ -169,26 +169,27 @@ def getSpeechForSpelling(text, locale=None, useCharacterDescriptions=False): for item in charDescList: if localeHasConjuncts: # item is a tuple containing character and its description - char = item[0] + speakCharAs = item[0] charDesc = item[1] else: # item is just a character. - char = item + speakCharAs = item if useCharacterDescriptions: - charDesc=characterProcessing.getCharacterDescription(locale,char.lower()) - uppercase=char.isupper() + charDesc=characterProcessing.getCharacterDescription(locale,speakCharAs.lower()) + uppercase=speakCharAs.isupper() if useCharacterDescriptions and charDesc: - char=charDesc[0] if textLength>1 else u"\u3001".join(charDesc) + IDEOGRAPHIC_COMMA = u"\u3001" + speakCharAs=charDesc[0] if textLength>1 else IDEOGRAPHIC_COMMA.join(charDesc) else: - char=characterProcessing.processSpeechSymbol(locale,char) + speakCharAs=characterProcessing.processSpeechSymbol(locale,speakCharAs) if uppercase and synthConfig["sayCapForCapitals"]: # Translators: cap will be spoken before the given letter when it is capitalized. - char=_("cap %s")%char + speakCharAs=_("cap %s")%speakCharAs if uppercase and synth.isSupported("pitch") and synthConfig["capPitchChange"]: yield PitchCommand(offset=synthConfig["capPitchChange"]) if config.conf['speech']['autoLanguageSwitching']: yield LangChangeCommand(locale) - if len(char) == 1 and synthConfig["useSpellingFunctionality"]: + if len(speakCharAs) == 1 and synthConfig["useSpellingFunctionality"]: if not charMode: yield CharacterModeCommand(True) charMode = True @@ -197,7 +198,7 @@ def getSpeechForSpelling(text, locale=None, useCharacterDescriptions=False): charMode = False if uppercase and synthConfig["beepForCapitals"]: yield BeepCommand(2000, 50) - yield char + yield speakCharAs if uppercase and synth.isSupported("pitch") and synthConfig["capPitchChange"]: yield PitchCommand() yield EndUtteranceCommand() @@ -484,18 +485,7 @@ def getIndentationSpeech(indentation, formatConfig): speak = True return (" ".join(res) if speak else "") -# Speech priorities. -#: Indicates that a speech sequence should have normal priority. -SPRI_NORMAL = 0 -#: Indicates that a speech sequence should be spoken after the next utterance of lower priority is complete. -SPRI_NEXT = 1 -#: Indicates that a speech sequence is very important and should be spoken right now, -#: interrupting low priority speech. -#: After it is spoken, interrupted speech will resume. -#: Note that this does not interrupt previously queued speech at the same priority. -SPRI_NOW = 2 -#: The speech priorities ordered from highest to lowest. -SPEECH_PRIORITIES = (SPRI_NOW, SPRI_NEXT, SPRI_NORMAL) +from .priorities import * def speak(speechSequence, symbolLevel=None, priority=None): """Speaks a sequence of text and speech commands @@ -1813,719 +1803,10 @@ def speakWithoutPauses(speechSequence,detectBreaks=True): return False speakWithoutPauses._pendingSpeechSequence=[] -class SpeechCommand(object): - """The base class for objects that can be inserted between strings of text to perform actions, change voice parameters, etc. - Note that some of these commands are processed by NVDA and are not directly passed to synth drivers. - synth drivers will only receive commands derived from L{SynthCommand}. - """ - -class SynthCommand(SpeechCommand): - """Commands that can be passed to synth drivers. - """ - -class IndexCommand(SynthCommand): - """Marks this point in the speech with an index. - When speech reaches this index, the synthesizer notifies NVDA, - thus allowing NVDA to perform actions at specific points in the speech; - e.g. synchronizing the cursor, beeping or playing a sound. - Callers should not use this directly. - Instead, use one of the subclasses of L{BaseCallbackCommand}. - NVDA handles the indexing and dispatches callbacks as appropriate. - """ - - def __init__(self,index): - """ - @param index: the value of this index - @type index: integer - """ - if not isinstance(index,int): raise ValueError("index must be int, not %s"%type(index)) - self.index=index - - def __repr__(self): - return "IndexCommand(%r)" % self.index - -class SynthParamCommand(SynthCommand): - """A synth command which changes a parameter for subsequent speech. - """ - #: Whether this command returns the parameter to its default value. - #: Note that the default might be configured by the user; - #: e.g. for pitch, rate, etc. - #: @type: bool - isDefault = False - -class CharacterModeCommand(SynthParamCommand): - """Turns character mode on and off for speech synths.""" - - def __init__(self,state): - """ - @param state: if true character mode is on, if false its turned off. - @type state: boolean - """ - if not isinstance(state,bool): raise ValueError("state must be boolean, not %s"%type(state)) - self.state=state - self.isDefault = not state - - def __repr__(self): - return "CharacterModeCommand(%r)" % self.state - -class LangChangeCommand(SynthParamCommand): - """A command to switch the language within speech.""" - - def __init__(self,lang): - """ - @param lang: the language to switch to: If None then the NVDA locale will be used. - @type lang: string - """ - self.lang=lang # if lang else languageHandler.getLanguage() - self.isDefault = not lang - - def __repr__(self): - return "LangChangeCommand (%r)"%self.lang - -class BreakCommand(SynthCommand): - """Insert a break between words. - """ - - def __init__(self, time=0): - """ - @param time: The duration of the pause to be inserted in milliseconds. - @param time: int - """ - self.time = time - - def __repr__(self): - return "BreakCommand(time=%d)" % self.time - -class EndUtteranceCommand(SpeechCommand): - """End the current utterance at this point in the speech. - Any text after this will be sent to the synthesizer as a separate utterance. - """ - - def __repr__(self): - return "EndUtteranceCommand()" - -class BaseProsodyCommand(SynthParamCommand): - """Base class for commands which change voice prosody; i.e. pitch, rate, etc. - The change to the setting is specified using either an offset or a multiplier, but not both. - The L{offset} and L{multiplier} properties convert between the two if necessary. - To return to the default value, specify neither. - This base class should not be instantiated directly. - """ - #: The name of the setting in the configuration; e.g. pitch, rate, etc. - settingName = None - - def __init__(self, offset=0, multiplier=1): - """Constructor. - Either of C{offset} or C{multiplier} may be specified, but not both. - @param offset: The amount by which to increase/decrease the user configured setting; - e.g. 30 increases by 30, -10 decreases by 10, 0 returns to the configured setting. - @type offset: int - @param multiplier: The number by which to multiply the user configured setting; - e.g. 0.5 is half, 1 returns to the configured setting. - @param multiplier: int/float - """ - if offset != 0 and multiplier != 1: - raise ValueError("offset and multiplier both specified") - self._offset = offset - self._multiplier = multiplier - self.isDefault = offset == 0 and multiplier == 1 - - @property - def defaultValue(self): - """The default value for the setting as configured by the user. - """ - synth = getSynth() - synthConf = config.conf["speech"][synth.name] - return synthConf[self.settingName] - - @property - def multiplier(self): - """The number by which to multiply the default value. - """ - if self._multiplier != 1: - # Constructed with multiplier. Just return it. - return self._multiplier - if self._offset == 0: - # Returning to default. - return 1 - # Calculate multiplier from default value and offset. - defaultVal = self.defaultValue - newVal = defaultVal + self._offset - return float(newVal) / defaultVal - - @property - def offset(self): - """The amount by which to increase/decrease the default value. - """ - if self._offset != 0: - # Constructed with offset. Just return it. - return self._offset - if self._multiplier == 1: - # Returning to default. - return 0 - # Calculate offset from default value and multiplier. - defaultVal = self.defaultValue - newVal = defaultVal * self._multiplier - return int(newVal - defaultVal) - - @property - def newValue(self): - """The new absolute value after the offset or multiplier is applied to the default value. - """ - if self._offset != 0: - # Calculate using offset. - return self.defaultValue + self._offset - if self._multiplier != 1: - # Calculate using multiplier. - return int(self.defaultValue * self._multiplier) - # Returning to default. - return self.defaultValue - - def __repr__(self): - if self._offset != 0: - param = "offset=%d" % self._offset - elif self._multiplier != 1: - param = "multiplier=%g" % self._multiplier - else: - param = "" - return "{type}({param})".format( - type=type(self).__name__, param=param) - -class PitchCommand(BaseProsodyCommand): - """Change the pitch of the voice. - """ - settingName = "pitch" - -class VolumeCommand(BaseProsodyCommand): - """Change the volume of the voice. - """ - settingName = "volume" - -class RateCommand(BaseProsodyCommand): - """Change the rate of the voice. - """ - settingName = "rate" - -class PhonemeCommand(SynthCommand): - """Insert a specific pronunciation. - This command accepts Unicode International Phonetic Alphabet (IPA) characters. - Note that this is not well supported by synthesizers. - """ - - def __init__(self, ipa, text=None): - """ - @param ipa: Unicode IPA characters. - @type ipa: unicode - @param text: Text to speak if the synthesizer does not support - some or all of the specified IPA characters, - C{None} to ignore this command instead. - @type text: unicode - """ - self.ipa = ipa - self.text = text - - def __repr__(self): - out = "PhonemeCommand(%r" % self.ipa - if self.text: - out += ", text=%r" % self.text - return out + ")" - -class BaseCallbackCommand(with_metaclass(ABCMeta, SpeechCommand)): - """Base class for commands which cause a function to be called when speech reaches them. - This class should not be instantiated directly. - It is designed to be subclassed to provide specific functionality; - e.g. L{BeepCommand}. - To supply a generic function to run, use L{CallbackCommand}. - This command is never passed to synth drivers. - """ - - @abstractmethod - def run(self): - """Code to run when speech reaches this command. - This method is executed in NVDA's main thread, - therefore must return as soon as practically possible, - otherwise it will block production of further speech and or other functionality in NVDA. - """ - -class CallbackCommand(BaseCallbackCommand): - """ - Call a function when speech reaches this point. - Note that the provided function is executed in NVDA's main thread, - therefore must return as soon as practically possible, - otherwise it will block production of further speech and or other functionality in NVDA. - """ - - def __init__(self, callback): - self._callback = callback - - def run(self,*args, **kwargs): - return self._callback(*args,**kwargs) - -class BeepCommand(BaseCallbackCommand): - """Produce a beep. - """ - - def __init__(self, hz, length, left=50, right=50): - self.hz = hz - self.length = length - self.left = left - self.right = right - - def run(self): - import tones - tones.beep(self.hz, self.length, left=self.left, right=self.right) - - def __repr__(self): - return "BeepCommand({hz}, {length}, left={left}, right={right})".format( - hz=self.hz, length=self.length, left=self.left, right=self.right) - -class WaveFileCommand(BaseCallbackCommand): - """Play a wave file. - """ - - def __init__(self, fileName): - self.fileName = fileName - - def run(self): - import nvwave - nvwave.playWaveFile(self.fileName, async=True) - - def __repr__(self): - return "WaveFileCommand(%r)" % self.fileName - -class ConfigProfileTriggerCommand(SpeechCommand): - """Applies (or stops applying) a configuration profile trigger to subsequent speech. - """ - - def __init__(self, trigger, enter=True): - """ - @param trigger: The configuration profile trigger. - @type trigger: L{config.ProfileTrigger} - @param enter: C{True} to apply the trigger, C{False} to stop applying it. - @type enter: bool - """ - self.trigger = trigger - self.enter = enter - trigger._shouldNotifyProfileSwitch = False - -class ParamChangeTracker(object): - """Keeps track of commands which change parameters from their defaults. - This is useful when an utterance needs to be split. - As you are processing a sequence, - you update the tracker with a parameter change using the L{update} method. - When you split the utterance, you use the L{getChanged} method to get - the parameters which have been changed from their defaults. - """ - - def __init__(self): - self._commands = {} - - def update(self, command): - """Update the tracker with a parameter change. - @param command: The parameter change command. - @type command: L{SynthParamCommand} - """ - paramType = type(command) - if command.isDefault: - # This no longer applies. - self._commands.pop(paramType, None) - else: - self._commands[paramType] = command - - def getChanged(self): - """Get the commands for the parameters which have been changed from their defaults. - @return: List of parameter change commands. - @type: list of L{SynthParamCommand} - """ - return self._commands.values() - -class _ManagerPriorityQueue(object): - """A speech queue for a specific priority. - This is intended for internal use by L{_SpeechManager} only. - Each priority has a separate queue. - It holds the pending speech sequences to be spoken, - as well as other information necessary to restore state when this queue - is preempted by a higher priority queue. - """ - - def __init__(self, priority): - self.priority = priority - #: The pending speech sequences to be spoken. - #: These are split at indexes, - #: so a single utterance might be split over multiple sequences. - self.pendingSequences = [] - #: The configuration profile triggers that have been entered during speech. - self.enteredProfileTriggers = [] - #: Keeps track of parameters that have been changed during an utterance. - self.paramTracker = ParamChangeTracker() - -class _SpeechManager(object): - """Manages queuing of speech utterances, calling callbacks at desired points in the speech, profile switching, prioritization, etc. - This is intended for internal use only. - It is used by higher level functions such as L{speak}. - - The high level flow of control is as follows: - 1. A speech sequence is queued with L{speak}, which in turn calls L{_queueSpeechSequence}. - 2. L{_processSpeechSequence} is called to normalize, process and split the input sequence. - It converts callbacks to indexes. - All indexing is assigned and managed by this class. - It maps any indexes to their corresponding callbacks. - It splits the sequence at indexes so we easily know what has completed speaking. - If there are end utterance commands, the sequence is split at that point. - We ensure there is an index at the end of all utterances so we know when they've finished speaking. - We ensure any config profile trigger commands are preceded by an utterance end. - Parameter changes are re-applied after utterance breaks. - We ensure any entered profile triggers are exited at the very end. - 3. L{_queueSpeechSequence} places these processed sequences in the queue - for the priority specified by the caller in step 1. - There is a separate queue for each priority. - 4. L{_pushNextSpeech} is called to begin pushing speech. - It looks for the highest priority queue with pending speech. - Because there's no other speech queued, that'll be the queue we just touched. - 5. If the input begins with a profile switch, it is applied immediately. - 6. L{_buildNextUtterance} is called to build a full utterance and it is sent to the synth. - 7. For every index reached, L{_handleIndex} is called. - The completed sequence is removed from L{_pendingSequences}. - If there is an associated callback, it is run. - If the index marks the end of an utterance, L{_pushNextSpeech} is called to push more speech. - 8. If there is another utterance before a profile switch, it is built and sent as per steps 6 and 7. - 9. In L{_pushNextSpeech}, if a profile switch is next, we wait for the synth to finish speaking before pushing more. - This is because we don't want to start speaking too early with a different synth. - L{_handleDoneSpeaking} is called when the synth finishes speaking. - It pushes more speech, which includes applying the profile switch. - 10. The flow then repeats from step 6 onwards until there are no more pending sequences. - 11. If another sequence is queued via L{speak} during speech, - it is processed and queued as per steps 2 and 3. - 12. If this is the first utterance at priority now, speech is interrupted - and L{_pushNextSpeech} is called. - Otherwise, L{_pushNextSpeech} is called when the current utterance completes - as per step 7. - 13. When L{_pushNextSpeech} is next called, it looks for the highest priority queue with pending speech. - If that priority is different to the priority of the utterance just spoken, - any relevant profile switches are applied to restore the state for this queue. - 14. If a lower priority utterance was interrupted in the middle, - L{_buildNextUtterance} applies any parameter changes that applied before the interruption. - 15. The flow then repeats from step 6 onwards until there are no more pending sequences. - - Note: - All of this activity is (and must be) synchronized and serialized on the main thread. - """ - - def __init__(self): - #: A counter for indexes sent to the synthesizer for callbacks, etc. - self._indexCounter = self._generateIndexes() - self._reset() - synthDriverHandler.synthIndexReached.register(self._onSynthIndexReached) - synthDriverHandler.synthDoneSpeaking.register(self._onSynthDoneSpeaking) - - #: Maximum index number to pass to synthesizers. - MAX_INDEX = 9999 - def _generateIndexes(self): - """Generator of index numbers. - We don't want to reuse index numbers too quickly, - as there can be race conditions when cancelling speech which might result - in an index from a previous utterance being treated as belonging to the current utterance. - However, we don't want the counter increasing indefinitely, - as some synths might not be able to handle huge numbers. - Therefore, we use a counter which starts at 1, counts up to L{MAX_INDEX}, - wraps back to 1 and continues cycling thus. - This maximum is arbitrary, but - it's small enough that any synth should be able to handle it - and large enough that previous indexes won't reasonably get reused - in the same or previous utterance. - """ - while True: - for index in xrange(1, self.MAX_INDEX + 1): - yield index - - def _reset(self): - #: The queues for each priority. - self._priQueues = {} - #: The priority queue for the utterance currently being spoken. - self._curPriQueue = None - #: Maps indexes to BaseCallbackCommands. - self._indexesToCallbacks = {} - #: Whether to push more speech when the synth reports it is done speaking. - self._shouldPushWhenDoneSpeaking = False - - def speak(self, speechSequence, priority): - # If speech isn't already in progress, we need to push the first speech. - push = self._curPriQueue is None - interrupt = self._queueSpeechSequence(speechSequence, priority) - if interrupt: - getSynth().cancel() - push = True - if push: - self._pushNextSpeech(True) - - def _queueSpeechSequence(self, inSeq, priority): - """ - @return: Whether to interrupt speech. - @rtype: bool - """ - outSeq = self._processSpeechSequence(inSeq) - queue = self._priQueues.get(priority) - if not queue: - queue = self._priQueues[priority] = _ManagerPriorityQueue(priority) - first = len(queue.pendingSequences) == 0 - queue.pendingSequences.extend(outSeq) - if priority is SPRI_NOW and first: - # If this is the first sequence at SPRI_NOW, interrupt speech. - return True - return False - - def _processSpeechSequence(self, inSeq): - paramTracker = ParamChangeTracker() - enteredTriggers = [] - outSeq = [] - outSeqs = [] - - def ensureEndUtterance(outSeq): - # We split at EndUtteranceCommands so the ends of utterances are easily found. - if outSeq: - # There have been commands since the last split. - outSeqs.append(outSeq) - lastOutSeq = outSeq - # Re-apply parameters that have been changed from their defaults. - outSeq = paramTracker.getChanged() - else: - lastOutSeq = outSeqs[-1] if outSeqs else None - lastCommand = lastOutSeq[-1] if lastOutSeq else None - if not lastCommand or isinstance(lastCommand, (EndUtteranceCommand, ConfigProfileTriggerCommand)): - # It doesn't make sense to start with or repeat EndUtteranceCommands. - # We also don't want an EndUtteranceCommand immediately after a ConfigProfileTriggerCommand. - return outSeq - if not isinstance(lastCommand, IndexCommand): - # Add an index so we know when we've reached the end of this utterance. - speechIndex = next(self._indexCounter) - lastOutSeq.append(IndexCommand(speechIndex)) - outSeqs.append([EndUtteranceCommand()]) - return outSeq - - for command in inSeq: - if isinstance(command, BaseCallbackCommand): - # When the synth reaches this point, we want to call the callback. - speechIndex = next(self._indexCounter) - outSeq.append(IndexCommand(speechIndex)) - self._indexesToCallbacks[speechIndex] = command - # We split at indexes so we easily know what has completed speaking. - outSeqs.append(outSeq) - outSeq = [] - continue - if isinstance(command, ConfigProfileTriggerCommand): - if not command.trigger.hasProfile: - # Ignore triggers that have no associated profile. - continue - if command.enter and command.trigger in enteredTriggers: - log.debugWarning("Request to enter trigger which has already been entered: %r" % command.trigger.spec) - continue - if not command.enter and command.trigger not in enteredTriggers: - log.debugWarning("Request to exit trigger which wasn't entered: %r" % command.trigger.spec) - continue - outSeq = ensureEndUtterance(outSeq) - outSeqs.append([command]) - if command.enter: - enteredTriggers.append(command.trigger) - else: - enteredTriggers.remove(command.trigger) - continue - if isinstance(command, EndUtteranceCommand): - outSeq = ensureEndUtterance(outSeq) - continue - if isinstance(command, SynthParamCommand): - paramTracker.update(command) - outSeq.append(command) - # Add the last sequence and make sure the sequence ends the utterance. - ensureEndUtterance(outSeq) - # Exit any profile triggers the caller didn't exit. - for trigger in reversed(enteredTriggers): - command = ConfigProfileTriggerCommand(trigger, False) - outSeqs.append([command]) - return outSeqs - - def _pushNextSpeech(self, doneSpeaking): - queue = self._getNextPriority() - if not queue: - # No more speech. - self._curPriQueue = None - return - if not self._curPriQueue: - # First utterance after no speech. - self._curPriQueue = queue - elif queue.priority > self._curPriQueue.priority: - # Preempted by higher priority speech. - if self._curPriQueue.enteredProfileTriggers: - if not doneSpeaking: - # Wait for the synth to finish speaking. - # _handleDoneSpeaking will call us again. - self._shouldPushWhenDoneSpeaking = True - return - self._exitProfileTriggers(self._curPriQueue.enteredProfileTriggers) - self._curPriQueue = queue - elif queue.priority < self._curPriQueue.priority: - # Resuming a preempted, lower priority queue. - if queue.enteredProfileTriggers: - if not doneSpeaking: - # Wait for the synth to finish speaking. - # _handleDoneSpeaking will call us again. - self._shouldPushWhenDoneSpeaking = True - return - self._restoreProfileTriggers(queue.enteredProfileTriggers) - self._curPriQueue = queue - while queue.pendingSequences and isinstance(queue.pendingSequences[0][0], ConfigProfileTriggerCommand): - if not doneSpeaking: - # Wait for the synth to finish speaking. - # _handleDoneSpeaking will call us again. - self._shouldPushWhenDoneSpeaking = True - return - self._switchProfile() - if not queue.pendingSequences: - # The last commands in this queue were profile switches. - # Call this method again in case other queues are waiting. - return self._pushNextSpeech(True) - seq = self._buildNextUtterance() - if seq: - getSynth().speak(seq) - - def _getNextPriority(self): - """Get the highest priority queue containing pending speech. - """ - for priority in SPEECH_PRIORITIES: - queue = self._priQueues.get(priority) - if not queue: - continue - if queue.pendingSequences: - return queue - return None - - def _buildNextUtterance(self): - """Since an utterance might be split over several sequences, - build a complete utterance to pass to the synth. - """ - utterance = [] - # If this utterance was preempted by higher priority speech, - # apply any parameters changed before the preemption. - params = self._curPriQueue.paramTracker.getChanged() - utterance.extend(params) - for seq in self._curPriQueue.pendingSequences: - if isinstance(seq[0], EndUtteranceCommand): - # The utterance ends here. - break - utterance.extend(seq) - return utterance - - def _onSynthIndexReached(self, synth=None, index=None): - if synth != getSynth(): - return - # This needs to be handled in the main thread. - queueHandler.queueFunction(queueHandler.eventQueue, self._handleIndex, index) - - def _removeCompletedFromQueue(self, index): - """Removes completed speech sequences from the queue. - @param index: The index just reached indicating a completed sequence. - @return: Tuple of (valid, endOfUtterance), - where valid indicates whether the index was valid and - endOfUtterance indicates whether this sequence was the end of the current utterance. - @rtype: (bool, bool) - """ - # Find the sequence that just completed speaking. - if not self._curPriQueue: - # No speech in progress. Probably from a previous utterance which was cancelled. - return False, False - for seqIndex, seq in enumerate(self._curPriQueue.pendingSequences): - lastCommand = seq[-1] if isinstance(seq, list) else None - if isinstance(lastCommand, IndexCommand) and index >= lastCommand.index: - endOfUtterance = isinstance(self._curPriQueue.pendingSequences[seqIndex + 1][0], EndUtteranceCommand) - if endOfUtterance: - # Remove the EndUtteranceCommand as well. - seqIndex += 1 - break # Found it! - else: - # Unknown index. Probably from a previous utterance which was cancelled. - return False, False - if endOfUtterance: - # These params may not apply to the next utterance if it was queued separately, - # so reset the tracker. - # The next utterance will include the commands again if they do still apply. - self._curPriQueue.paramTracker = ParamChangeTracker() - else: - # Keep track of parameters changed so far. - # This is necessary in case this utterance is preempted by higher priority speech. - for seqIndex in xrange(seqIndex + 1): - seq = self._curPriQueue.pendingSequences[seqIndex] - for command in seq: - if isinstance(command, SynthParamCommand): - self._curPriQueue.paramTracker.update(command) - # This sequence is done, so we don't need to track it any more. - del self._curPriQueue.pendingSequences[:seqIndex + 1] - return True, endOfUtterance - - def _handleIndex(self, index): - valid, endOfUtterance = self._removeCompletedFromQueue(index) - if not valid: - return - callbackCommand = self._indexesToCallbacks.pop(index, None) - if callbackCommand: - try: - callbackCommand.run() - except: - log.exception("Error running speech callback") - if endOfUtterance: - self._pushNextSpeech(False) - - def _onSynthDoneSpeaking(self, synth=None): - if synth != getSynth(): - return - # This needs to be handled in the main thread. - queueHandler.queueFunction(queueHandler.eventQueue, self._handleDoneSpeaking) - - def _handleDoneSpeaking(self): - if self._shouldPushWhenDoneSpeaking: - self._shouldPushWhenDoneSpeaking = False - self._pushNextSpeech(True) - - def _switchProfile(self): - command = self._curPriQueue.pendingSequences.pop(0)[0] - assert isinstance(command, ConfigProfileTriggerCommand), "First pending command should be a ConfigProfileTriggerCommand" - if command.enter: - try: - command.trigger.enter() - except: - log.exception("Error entering new trigger %r" % command.trigger.spec) - self._curPriQueue.enteredProfileTriggers.append(command.trigger) - else: - try: - command.trigger.exit() - except: - log.exception("Error exiting active trigger %r" % command.trigger.spec) - self._curPriQueue.enteredProfileTriggers.remove(command.trigger) - synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) - - def _exitProfileTriggers(self, triggers): - for trigger in reversed(triggers): - try: - trigger.exit() - except: - log.exception("Error exiting profile trigger %r" % command.trigger.spec) - synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) - - def _restoreProfileTriggers(self, triggers): - for trigger in triggers: - try: - trigger.enter() - except: - log.exception("Error entering profile trigger %r" % command.trigger.spec) - synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) - - def cancel(self): - getSynth().cancel() - if self._curPriQueue and self._curPriQueue.enteredProfileTriggers: - self._exitProfileTriggers(self._curPriQueue.enteredProfileTriggers) - self._reset() - +from .manager import SpeechManager #: The singleton _SpeechManager instance used for speech functions. #: @type: L{_SpeechManager} -_manager = _SpeechManager() +_manager = SpeechManager() def clearTypedWordBuffer(): """ diff --git a/source/speech/commands.py b/source/speech/commands.py new file mode 100644 index 00000000000..31091a29e77 --- /dev/null +++ b/source/speech/commands.py @@ -0,0 +1,308 @@ +# -*- coding: UTF-8 -*- +#A part of NonVisual Desktop Access (NVDA) +#This file is covered by the GNU General Public License. +#See the file COPYING for more details. +#Copyright (C) 2006-2019 NV Access Limited + +"""Commands that can be embedded in a speech sequence for changing synth parameters, playing sounds or running other callbacks.""" + +from abc import ABCMeta, abstractmethod +from six import with_metaclass +import config +import languageHandler +from synthDriverHandler import getSynth + +class SpeechCommand(object): + """The base class for objects that can be inserted between strings of text to perform actions, change voice parameters, etc. + Note that some of these commands are processed by NVDA and are not directly passed to synth drivers. + synth drivers will only receive commands derived from L{SynthCommand}. + """ + +class SynthCommand(SpeechCommand): + """Commands that can be passed to synth drivers. + """ + +class IndexCommand(SynthCommand): + """Marks this point in the speech with an index. + When speech reaches this index, the synthesizer notifies NVDA, + thus allowing NVDA to perform actions at specific points in the speech; + e.g. synchronizing the cursor, beeping or playing a sound. + Callers should not use this directly. + Instead, use one of the subclasses of L{BaseCallbackCommand}. + NVDA handles the indexing and dispatches callbacks as appropriate. + """ + + def __init__(self,index): + """ + @param index: the value of this index + @type index: integer + """ + if not isinstance(index,int): raise ValueError("index must be int, not %s"%type(index)) + self.index=index + + def __repr__(self): + return "IndexCommand(%r)" % self.index + +class SynthParamCommand(SynthCommand): + """A synth command which changes a parameter for subsequent speech. + """ + #: Whether this command returns the parameter to its default value. + #: Note that the default might be configured by the user; + #: e.g. for pitch, rate, etc. + #: @type: bool + isDefault = False + +class CharacterModeCommand(SynthParamCommand): + """Turns character mode on and off for speech synths.""" + + def __init__(self,state): + """ + @param state: if true character mode is on, if false its turned off. + @type state: boolean + """ + if not isinstance(state,bool): raise ValueError("state must be boolean, not %s"%type(state)) + self.state=state + self.isDefault = not state + + def __repr__(self): + return "CharacterModeCommand(%r)" % self.state + +class LangChangeCommand(SynthParamCommand): + """A command to switch the language within speech.""" + + def __init__(self,lang): + """ + @param lang: the language to switch to: If None then the NVDA locale will be used. + @type lang: string + """ + self.lang=lang # if lang else languageHandler.getLanguage() + self.isDefault = not lang + + def __repr__(self): + return "LangChangeCommand (%r)"%self.lang + +class BreakCommand(SynthCommand): + """Insert a break between words. + """ + + def __init__(self, time=0): + """ + @param time: The duration of the pause to be inserted in milliseconds. + @param time: int + """ + self.time = time + + def __repr__(self): + return "BreakCommand(time=%d)" % self.time + +class EndUtteranceCommand(SpeechCommand): + """End the current utterance at this point in the speech. + Any text after this will be sent to the synthesizer as a separate utterance. + """ + + def __repr__(self): + return "EndUtteranceCommand()" + +class BaseProsodyCommand(SynthParamCommand): + """Base class for commands which change voice prosody; i.e. pitch, rate, etc. + The change to the setting is specified using either an offset or a multiplier, but not both. + The L{offset} and L{multiplier} properties convert between the two if necessary. + To return to the default value, specify neither. + This base class should not be instantiated directly. + """ + #: The name of the setting in the configuration; e.g. pitch, rate, etc. + settingName = None + + def __init__(self, offset=0, multiplier=1): + """Constructor. + Either of C{offset} or C{multiplier} may be specified, but not both. + @param offset: The amount by which to increase/decrease the user configured setting; + e.g. 30 increases by 30, -10 decreases by 10, 0 returns to the configured setting. + @type offset: int + @param multiplier: The number by which to multiply the user configured setting; + e.g. 0.5 is half, 1 returns to the configured setting. + @param multiplier: int/float + """ + if offset != 0 and multiplier != 1: + raise ValueError("offset and multiplier both specified") + self._offset = offset + self._multiplier = multiplier + self.isDefault = offset == 0 and multiplier == 1 + + @property + def defaultValue(self): + """The default value for the setting as configured by the user. + """ + synth = getSynth() + synthConf = config.conf["speech"][synth.name] + return synthConf[self.settingName] + + @property + def multiplier(self): + """The number by which to multiply the default value. + """ + if self._multiplier != 1: + # Constructed with multiplier. Just return it. + return self._multiplier + if self._offset == 0: + # Returning to default. + return 1 + # Calculate multiplier from default value and offset. + defaultVal = self.defaultValue + newVal = defaultVal + self._offset + return float(newVal) / defaultVal + + @property + def offset(self): + """The amount by which to increase/decrease the default value. + """ + if self._offset != 0: + # Constructed with offset. Just return it. + return self._offset + if self._multiplier == 1: + # Returning to default. + return 0 + # Calculate offset from default value and multiplier. + defaultVal = self.defaultValue + newVal = defaultVal * self._multiplier + return int(newVal - defaultVal) + + @property + def newValue(self): + """The new absolute value after the offset or multiplier is applied to the default value. + """ + if self._offset != 0: + # Calculate using offset. + return self.defaultValue + self._offset + if self._multiplier != 1: + # Calculate using multiplier. + return int(self.defaultValue * self._multiplier) + # Returning to default. + return self.defaultValue + + def __repr__(self): + if self._offset != 0: + param = "offset=%d" % self._offset + elif self._multiplier != 1: + param = "multiplier=%g" % self._multiplier + else: + param = "" + return "{type}({param})".format( + type=type(self).__name__, param=param) + +class PitchCommand(BaseProsodyCommand): + """Change the pitch of the voice. + """ + settingName = "pitch" + +class VolumeCommand(BaseProsodyCommand): + """Change the volume of the voice. + """ + settingName = "volume" + +class RateCommand(BaseProsodyCommand): + """Change the rate of the voice. + """ + settingName = "rate" + +class PhonemeCommand(SynthCommand): + """Insert a specific pronunciation. + This command accepts Unicode International Phonetic Alphabet (IPA) characters. + Note that this is not well supported by synthesizers. + """ + + def __init__(self, ipa, text=None): + """ + @param ipa: Unicode IPA characters. + @type ipa: unicode + @param text: Text to speak if the synthesizer does not support + some or all of the specified IPA characters, + C{None} to ignore this command instead. + @type text: unicode + """ + self.ipa = ipa + self.text = text + + def __repr__(self): + out = "PhonemeCommand(%r" % self.ipa + if self.text: + out += ", text=%r" % self.text + return out + ")" + +class BaseCallbackCommand(with_metaclass(ABCMeta, SpeechCommand)): + """Base class for commands which cause a function to be called when speech reaches them. + This class should not be instantiated directly. + It is designed to be subclassed to provide specific functionality; + e.g. L{BeepCommand}. + To supply a generic function to run, use L{CallbackCommand}. + This command is never passed to synth drivers. + """ + + @abstractmethod + def run(self): + """Code to run when speech reaches this command. + This method is executed in NVDA's main thread, + therefore must return as soon as practically possible, + otherwise it will block production of further speech and or other functionality in NVDA. + """ + +class CallbackCommand(BaseCallbackCommand): + """ + Call a function when speech reaches this point. + Note that the provided function is executed in NVDA's main thread, + therefore must return as soon as practically possible, + otherwise it will block production of further speech and or other functionality in NVDA. + """ + + def __init__(self, callback): + self._callback = callback + + def run(self,*args, **kwargs): + return self._callback(*args,**kwargs) + +class BeepCommand(BaseCallbackCommand): + """Produce a beep. + """ + + def __init__(self, hz, length, left=50, right=50): + self.hz = hz + self.length = length + self.left = left + self.right = right + + def run(self): + import tones + tones.beep(self.hz, self.length, left=self.left, right=self.right) + + def __repr__(self): + return "BeepCommand({hz}, {length}, left={left}, right={right})".format( + hz=self.hz, length=self.length, left=self.left, right=self.right) + +class WaveFileCommand(BaseCallbackCommand): + """Play a wave file. + """ + + def __init__(self, fileName): + self.fileName = fileName + + def run(self): + import nvwave + nvwave.playWaveFile(self.fileName, async=True) + + def __repr__(self): + return "WaveFileCommand(%r)" % self.fileName + +class ConfigProfileTriggerCommand(SpeechCommand): + """Applies (or stops applying) a configuration profile trigger to subsequent speech. + """ + + def __init__(self, trigger, enter=True): + """ + @param trigger: The configuration profile trigger. + @type trigger: L{config.ProfileTrigger} + @param enter: C{True} to apply the trigger, C{False} to stop applying it. + @type enter: bool + """ + self.trigger = trigger + self.enter = enter + trigger._shouldNotifyProfileSwitch = False diff --git a/source/speech/manager.py b/source/speech/manager.py new file mode 100644 index 00000000000..685117ab1f0 --- /dev/null +++ b/source/speech/manager.py @@ -0,0 +1,426 @@ +# -*- coding: UTF-8 -*- +#A part of NonVisual Desktop Access (NVDA) +#This file is covered by the GNU General Public License. +#See the file COPYING for more details. +#Copyright (C) 2006-2019 NV Access Limited + +from logHandler import log +import queueHandler +import synthDriverHandler +from .commands import * +from .priorities import * + +class ParamChangeTracker(object): + """Keeps track of commands which change parameters from their defaults. + This is useful when an utterance needs to be split. + As you are processing a sequence, + you update the tracker with a parameter change using the L{update} method. + When you split the utterance, you use the L{getChanged} method to get + the parameters which have been changed from their defaults. + """ + + def __init__(self): + self._commands = {} + + def update(self, command): + """Update the tracker with a parameter change. + @param command: The parameter change command. + @type command: L{SynthParamCommand} + """ + paramType = type(command) + if command.isDefault: + # This no longer applies. + self._commands.pop(paramType, None) + else: + self._commands[paramType] = command + + def getChanged(self): + """Get the commands for the parameters which have been changed from their defaults. + @return: List of parameter change commands. + @type: list of L{SynthParamCommand} + """ + return self._commands.values() + +class _ManagerPriorityQueue(object): + """A speech queue for a specific priority. + This is intended for internal use by L{_SpeechManager} only. + Each priority has a separate queue. + It holds the pending speech sequences to be spoken, + as well as other information necessary to restore state when this queue + is preempted by a higher priority queue. + """ + + def __init__(self, priority): + self.priority = priority + #: The pending speech sequences to be spoken. + #: These are split at indexes, + #: so a single utterance might be split over multiple sequences. + self.pendingSequences = [] + #: The configuration profile triggers that have been entered during speech. + self.enteredProfileTriggers = [] + #: Keeps track of parameters that have been changed during an utterance. + self.paramTracker = ParamChangeTracker() + +class SpeechManager(object): + """Manages queuing of speech utterances, calling callbacks at desired points in the speech, profile switching, prioritization, etc. + This is intended for internal use only. + It is used by higher level functions such as L{speak}. + + The high level flow of control is as follows: + 1. A speech sequence is queued with L{speak}, which in turn calls L{_queueSpeechSequence}. + 2. L{_processSpeechSequence} is called to normalize, process and split the input sequence. + It converts callbacks to indexes. + All indexing is assigned and managed by this class. + It maps any indexes to their corresponding callbacks. + It splits the sequence at indexes so we easily know what has completed speaking. + If there are end utterance commands, the sequence is split at that point. + We ensure there is an index at the end of all utterances so we know when they've finished speaking. + We ensure any config profile trigger commands are preceded by an utterance end. + Parameter changes are re-applied after utterance breaks. + We ensure any entered profile triggers are exited at the very end. + 3. L{_queueSpeechSequence} places these processed sequences in the queue + for the priority specified by the caller in step 1. + There is a separate queue for each priority. + 4. L{_pushNextSpeech} is called to begin pushing speech. + It looks for the highest priority queue with pending speech. + Because there's no other speech queued, that'll be the queue we just touched. + 5. If the input begins with a profile switch, it is applied immediately. + 6. L{_buildNextUtterance} is called to build a full utterance and it is sent to the synth. + 7. For every index reached, L{_handleIndex} is called. + The completed sequence is removed from L{_pendingSequences}. + If there is an associated callback, it is run. + If the index marks the end of an utterance, L{_pushNextSpeech} is called to push more speech. + 8. If there is another utterance before a profile switch, it is built and sent as per steps 6 and 7. + 9. In L{_pushNextSpeech}, if a profile switch is next, we wait for the synth to finish speaking before pushing more. + This is because we don't want to start speaking too early with a different synth. + L{_handleDoneSpeaking} is called when the synth finishes speaking. + It pushes more speech, which includes applying the profile switch. + 10. The flow then repeats from step 6 onwards until there are no more pending sequences. + 11. If another sequence is queued via L{speak} during speech, + it is processed and queued as per steps 2 and 3. + 12. If this is the first utterance at priority now, speech is interrupted + and L{_pushNextSpeech} is called. + Otherwise, L{_pushNextSpeech} is called when the current utterance completes + as per step 7. + 13. When L{_pushNextSpeech} is next called, it looks for the highest priority queue with pending speech. + If that priority is different to the priority of the utterance just spoken, + any relevant profile switches are applied to restore the state for this queue. + 14. If a lower priority utterance was interrupted in the middle, + L{_buildNextUtterance} applies any parameter changes that applied before the interruption. + 15. The flow then repeats from step 6 onwards until there are no more pending sequences. + + Note: + All of this activity is (and must be) synchronized and serialized on the main thread. + """ + + def __init__(self): + #: A counter for indexes sent to the synthesizer for callbacks, etc. + self._indexCounter = self._generateIndexes() + self._reset() + synthDriverHandler.synthIndexReached.register(self._onSynthIndexReached) + synthDriverHandler.synthDoneSpeaking.register(self._onSynthDoneSpeaking) + + #: Maximum index number to pass to synthesizers. + MAX_INDEX = 9999 + def _generateIndexes(self): + """Generator of index numbers. + We don't want to reuse index numbers too quickly, + as there can be race conditions when cancelling speech which might result + in an index from a previous utterance being treated as belonging to the current utterance. + However, we don't want the counter increasing indefinitely, + as some synths might not be able to handle huge numbers. + Therefore, we use a counter which starts at 1, counts up to L{MAX_INDEX}, + wraps back to 1 and continues cycling thus. + This maximum is arbitrary, but + it's small enough that any synth should be able to handle it + and large enough that previous indexes won't reasonably get reused + in the same or previous utterance. + """ + while True: + for index in xrange(1, self.MAX_INDEX + 1): + yield index + + def _reset(self): + #: The queues for each priority. + self._priQueues = {} + #: The priority queue for the utterance currently being spoken. + self._curPriQueue = None + #: Maps indexes to BaseCallbackCommands. + self._indexesToCallbacks = {} + #: Whether to push more speech when the synth reports it is done speaking. + self._shouldPushWhenDoneSpeaking = False + + def speak(self, speechSequence, priority): + # If speech isn't already in progress, we need to push the first speech. + push = self._curPriQueue is None + interrupt = self._queueSpeechSequence(speechSequence, priority) + if interrupt: + getSynth().cancel() + push = True + if push: + self._pushNextSpeech(True) + + def _queueSpeechSequence(self, inSeq, priority): + """ + @return: Whether to interrupt speech. + @rtype: bool + """ + outSeq = self._processSpeechSequence(inSeq) + queue = self._priQueues.get(priority) + if not queue: + queue = self._priQueues[priority] = _ManagerPriorityQueue(priority) + first = len(queue.pendingSequences) == 0 + queue.pendingSequences.extend(outSeq) + if priority is SPRI_NOW and first: + # If this is the first sequence at SPRI_NOW, interrupt speech. + return True + return False + + def _processSpeechSequence(self, inSeq): + paramTracker = ParamChangeTracker() + enteredTriggers = [] + outSeq = [] + outSeqs = [] + + def ensureEndUtterance(outSeq): + # We split at EndUtteranceCommands so the ends of utterances are easily found. + if outSeq: + # There have been commands since the last split. + outSeqs.append(outSeq) + lastOutSeq = outSeq + # Re-apply parameters that have been changed from their defaults. + outSeq = paramTracker.getChanged() + else: + lastOutSeq = outSeqs[-1] if outSeqs else None + lastCommand = lastOutSeq[-1] if lastOutSeq else None + if not lastCommand or isinstance(lastCommand, (EndUtteranceCommand, ConfigProfileTriggerCommand)): + # It doesn't make sense to start with or repeat EndUtteranceCommands. + # We also don't want an EndUtteranceCommand immediately after a ConfigProfileTriggerCommand. + return outSeq + if not isinstance(lastCommand, IndexCommand): + # Add an index so we know when we've reached the end of this utterance. + speechIndex = next(self._indexCounter) + lastOutSeq.append(IndexCommand(speechIndex)) + outSeqs.append([EndUtteranceCommand()]) + return outSeq + + for command in inSeq: + if isinstance(command, BaseCallbackCommand): + # When the synth reaches this point, we want to call the callback. + speechIndex = next(self._indexCounter) + outSeq.append(IndexCommand(speechIndex)) + self._indexesToCallbacks[speechIndex] = command + # We split at indexes so we easily know what has completed speaking. + outSeqs.append(outSeq) + outSeq = [] + continue + if isinstance(command, ConfigProfileTriggerCommand): + if not command.trigger.hasProfile: + # Ignore triggers that have no associated profile. + continue + if command.enter and command.trigger in enteredTriggers: + log.debugWarning("Request to enter trigger which has already been entered: %r" % command.trigger.spec) + continue + if not command.enter and command.trigger not in enteredTriggers: + log.debugWarning("Request to exit trigger which wasn't entered: %r" % command.trigger.spec) + continue + outSeq = ensureEndUtterance(outSeq) + outSeqs.append([command]) + if command.enter: + enteredTriggers.append(command.trigger) + else: + enteredTriggers.remove(command.trigger) + continue + if isinstance(command, EndUtteranceCommand): + outSeq = ensureEndUtterance(outSeq) + continue + if isinstance(command, SynthParamCommand): + paramTracker.update(command) + outSeq.append(command) + # Add the last sequence and make sure the sequence ends the utterance. + ensureEndUtterance(outSeq) + # Exit any profile triggers the caller didn't exit. + for trigger in reversed(enteredTriggers): + command = ConfigProfileTriggerCommand(trigger, False) + outSeqs.append([command]) + return outSeqs + + def _pushNextSpeech(self, doneSpeaking): + queue = self._getNextPriority() + if not queue: + # No more speech. + self._curPriQueue = None + return + if not self._curPriQueue: + # First utterance after no speech. + self._curPriQueue = queue + elif queue.priority > self._curPriQueue.priority: + # Preempted by higher priority speech. + if self._curPriQueue.enteredProfileTriggers: + if not doneSpeaking: + # Wait for the synth to finish speaking. + # _handleDoneSpeaking will call us again. + self._shouldPushWhenDoneSpeaking = True + return + self._exitProfileTriggers(self._curPriQueue.enteredProfileTriggers) + self._curPriQueue = queue + elif queue.priority < self._curPriQueue.priority: + # Resuming a preempted, lower priority queue. + if queue.enteredProfileTriggers: + if not doneSpeaking: + # Wait for the synth to finish speaking. + # _handleDoneSpeaking will call us again. + self._shouldPushWhenDoneSpeaking = True + return + self._restoreProfileTriggers(queue.enteredProfileTriggers) + self._curPriQueue = queue + while queue.pendingSequences and isinstance(queue.pendingSequences[0][0], ConfigProfileTriggerCommand): + if not doneSpeaking: + # Wait for the synth to finish speaking. + # _handleDoneSpeaking will call us again. + self._shouldPushWhenDoneSpeaking = True + return + self._switchProfile() + if not queue.pendingSequences: + # The last commands in this queue were profile switches. + # Call this method again in case other queues are waiting. + return self._pushNextSpeech(True) + seq = self._buildNextUtterance() + if seq: + getSynth().speak(seq) + + def _getNextPriority(self): + """Get the highest priority queue containing pending speech. + """ + for priority in SPEECH_PRIORITIES: + queue = self._priQueues.get(priority) + if not queue: + continue + if queue.pendingSequences: + return queue + return None + + def _buildNextUtterance(self): + """Since an utterance might be split over several sequences, + build a complete utterance to pass to the synth. + """ + utterance = [] + # If this utterance was preempted by higher priority speech, + # apply any parameters changed before the preemption. + params = self._curPriQueue.paramTracker.getChanged() + utterance.extend(params) + for seq in self._curPriQueue.pendingSequences: + if isinstance(seq[0], EndUtteranceCommand): + # The utterance ends here. + break + utterance.extend(seq) + return utterance + + def _onSynthIndexReached(self, synth=None, index=None): + if synth != getSynth(): + return + # This needs to be handled in the main thread. + queueHandler.queueFunction(queueHandler.eventQueue, self._handleIndex, index) + + def _removeCompletedFromQueue(self, index): + """Removes completed speech sequences from the queue. + @param index: The index just reached indicating a completed sequence. + @return: Tuple of (valid, endOfUtterance), + where valid indicates whether the index was valid and + endOfUtterance indicates whether this sequence was the end of the current utterance. + @rtype: (bool, bool) + """ + # Find the sequence that just completed speaking. + if not self._curPriQueue: + # No speech in progress. Probably from a previous utterance which was cancelled. + return False, False + for seqIndex, seq in enumerate(self._curPriQueue.pendingSequences): + lastCommand = seq[-1] if isinstance(seq, list) else None + if isinstance(lastCommand, IndexCommand) and index >= lastCommand.index: + endOfUtterance = isinstance(self._curPriQueue.pendingSequences[seqIndex + 1][0], EndUtteranceCommand) + if endOfUtterance: + # Remove the EndUtteranceCommand as well. + seqIndex += 1 + break # Found it! + else: + # Unknown index. Probably from a previous utterance which was cancelled. + return False, False + if endOfUtterance: + # These params may not apply to the next utterance if it was queued separately, + # so reset the tracker. + # The next utterance will include the commands again if they do still apply. + self._curPriQueue.paramTracker = ParamChangeTracker() + else: + # Keep track of parameters changed so far. + # This is necessary in case this utterance is preempted by higher priority speech. + for seqIndex in xrange(seqIndex + 1): + seq = self._curPriQueue.pendingSequences[seqIndex] + for command in seq: + if isinstance(command, SynthParamCommand): + self._curPriQueue.paramTracker.update(command) + # This sequence is done, so we don't need to track it any more. + del self._curPriQueue.pendingSequences[:seqIndex + 1] + return True, endOfUtterance + + def _handleIndex(self, index): + valid, endOfUtterance = self._removeCompletedFromQueue(index) + if not valid: + return + callbackCommand = self._indexesToCallbacks.pop(index, None) + if callbackCommand: + try: + callbackCommand.run() + except: + log.exception("Error running speech callback") + if endOfUtterance: + self._pushNextSpeech(False) + + def _onSynthDoneSpeaking(self, synth=None): + if synth != getSynth(): + return + # This needs to be handled in the main thread. + queueHandler.queueFunction(queueHandler.eventQueue, self._handleDoneSpeaking) + + def _handleDoneSpeaking(self): + if self._shouldPushWhenDoneSpeaking: + self._shouldPushWhenDoneSpeaking = False + self._pushNextSpeech(True) + + def _switchProfile(self): + command = self._curPriQueue.pendingSequences.pop(0)[0] + assert isinstance(command, ConfigProfileTriggerCommand), "First pending command should be a ConfigProfileTriggerCommand" + if command.enter: + try: + command.trigger.enter() + except: + log.exception("Error entering new trigger %r" % command.trigger.spec) + self._curPriQueue.enteredProfileTriggers.append(command.trigger) + else: + try: + command.trigger.exit() + except: + log.exception("Error exiting active trigger %r" % command.trigger.spec) + self._curPriQueue.enteredProfileTriggers.remove(command.trigger) + synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) + + def _exitProfileTriggers(self, triggers): + for trigger in reversed(triggers): + try: + trigger.exit() + except: + log.exception("Error exiting profile trigger %r" % trigger.spec) + synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) + + def _restoreProfileTriggers(self, triggers): + for trigger in triggers: + try: + trigger.enter() + except: + log.exception("Error entering profile trigger %r" % trigger.spec) + synthDriverHandler.handlePostConfigProfileSwitch(resetSpeechIfNeeded=False) + + def cancel(self): + getSynth().cancel() + if self._curPriQueue and self._curPriQueue.enteredProfileTriggers: + self._exitProfileTriggers(self._curPriQueue.enteredProfileTriggers) + self._reset() diff --git a/source/speech/priorities.py b/source/speech/priorities.py new file mode 100644 index 00000000000..291a7941cac --- /dev/null +++ b/source/speech/priorities.py @@ -0,0 +1,19 @@ +# -*- coding: UTF-8 -*- +#A part of NonVisual Desktop Access (NVDA) +#This file is covered by the GNU General Public License. +#See the file COPYING for more details. +#Copyright (C) 2006-2019 NV Access Limited + +"""Speech priority constants. """ + +#: Indicates that a speech sequence should have normal priority. +SPRI_NORMAL = 0 +#: Indicates that a speech sequence should be spoken after the next utterance of lower priority is complete. +SPRI_NEXT = 1 +#: Indicates that a speech sequence is very important and should be spoken right now, +#: interrupting low priority speech. +#: After it is spoken, interrupted speech will resume. +#: Note that this does not interrupt previously queued speech at the same priority. +SPRI_NOW = 2 +#: The speech priorities ordered from highest to lowest. +SPEECH_PRIORITIES = (SPRI_NOW, SPRI_NEXT, SPRI_NORMAL) diff --git a/source/synthDrivers/_espeak.py b/source/synthDrivers/_espeak.py index 9d0881d407e..7f0233dbbca 100755 --- a/source/synthDrivers/_espeak.py +++ b/source/synthDrivers/_espeak.py @@ -121,6 +121,10 @@ class espeak_VOICE(Structure): def __eq__(self, other): return isinstance(other, type(self)) and addressof(self) == addressof(other) +# constants that can be returned by espeak_callback +CALLBACK_CONTINUE_SYNTHESIS=0 +CALLBACK_ABORT_SYNTHESIS=1 + t_espeak_callback=CFUNCTYPE(c_int,POINTER(c_short),c_int,POINTER(espeak_EVENT)) @t_espeak_callback @@ -128,15 +132,17 @@ def callback(wav,numsamples,event): try: global player, isSpeaking, _numBytesPushed if not isSpeaking: - return 1 + return CALLBACK_ABORT_SYNTHESIS indexes = [] for e in event: if e.type==espeakEVENT_MARK: indexNum = int(e.id.name) # e.audio_position is ms since the start of this utterance. # Convert to bytes since the start of the utterance. - # samplesPerSec * 2 bytes per sample / 1000 ms per sec gives us bytes per ms. - indexByte = e.audio_position * player.samplesPerSec * 2 / 1000 + BYTES_PER_SAMPLE = 2 + MS_PER_SEC = 1000 + bytesPerMS = player.samplesPerSec * BYTES_PER_SAMPLE / MS_PER_SEC + indexByte = e.audio_position * bytesPerMS # Subtract bytes in the utterance that have already been handled # to give us the byte offset into the samples for this callback. indexByte -= _numBytesPushed @@ -147,7 +153,7 @@ def callback(wav,numsamples,event): player.idle() onIndexReached(None) isSpeaking = False - return 0 + return CALLBACK_CONTINUE_SYNTHESIS wav = string_at(wav, numsamples * sizeof(c_short)) if numsamples>0 else "" prevByte = 0 for indexNum, indexByte in indexes: @@ -155,10 +161,10 @@ def callback(wav,numsamples,event): onDone=lambda indexNum=indexNum: onIndexReached(indexNum)) prevByte = indexByte if not isSpeaking: - return 1 + return CALLBACK_ABORT_SYNTHESIS player.feed(wav[prevByte:]) _numBytesPushed += len(wav) - return 0 + return CALLBACK_CONTINUE_SYNTHESIS except: log.error("callback", exc_info=True) @@ -322,11 +328,12 @@ def initialize(indexCallback=None): player = nvwave.WavePlayer(channels=1, samplesPerSec=sampleRate, bitsPerSample=16, outputDevice=config.conf["speech"]["outputDevice"], buffered=True) + onIndexReached = indexCallback espeakDLL.espeak_SetSynthCallback(callback) bgQueue = queue.Queue() bgThread=BgThread() bgThread.start() - onIndexReached = indexCallback + def terminate(): global bgThread, bgQueue, player, espeakDLL , onIndexReached