From 6a6e2ef14226bc1f224b3113efaf70b16e1ca560 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 00:21:09 +0000 Subject: [PATCH 01/39] attempt at fixing event loop --- pyttsx3/driver.py | 7 ++- pyttsx3/drivers/_espeak.py | 25 +++++++-- pyttsx3/drivers/espeak.py | 105 ++++++++++++++++--------------------- test.py | 76 +++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 65 deletions(-) create mode 100644 test.py diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index 3f6e0ed..b493eb8 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -49,6 +49,7 @@ def __init__(self, engine, driverName, debug): self._engine = engine self._queue = [] self._busy = True + self._looping = False self._name = None self._iterator = None self._debug = debug @@ -193,15 +194,19 @@ def startLoop(self, useDriverLoop): """ Called by the engine to start an event loop. """ + print("driver.startLoop setting looping to true..") + self._looping = True if useDriverLoop: self._driver.startLoop() else: - self._iterator = self._driver.iterate() + self._iterator = self._driver.iterate() or iter([]) def endLoop(self, useDriverLoop): """ Called by the engine to stop an event loop. """ + print("DriverProxy.endLoop called, setting looping to False") + self._looping = False self._queue = [] self._driver.stop() if useDriverLoop: diff --git a/pyttsx3/drivers/_espeak.py b/pyttsx3/drivers/_espeak.py index 6251a27..ad0c76a 100644 --- a/pyttsx3/drivers/_espeak.py +++ b/pyttsx3/drivers/_espeak.py @@ -212,6 +212,8 @@ def Synth( flags=0, user_data=None, ): + if isinstance(text, str): + text = text.encode("utf-8") return cSynth( text, len(text) * 10, @@ -528,16 +530,31 @@ def ListVoices(voice_spec=None): if __name__ == "__main__": def synth_cb(wav, numsample, events): - print(numsample, end="") + print(f"Callback received: numsample={numsample}") i = 0 while True: - if events[i].type == EVENT_LIST_TERMINATED: + event_type = events[i].type + if event_type == EVENT_LIST_TERMINATED: + print("Event: LIST_TERMINATED") break - print(events[i].type, end="") + elif event_type == EVENT_WORD: + print("Event: WORD") + elif event_type == EVENT_SENTENCE: + print("Event: SENTENCE") + elif event_type == EVENT_MARK: + print("Event: MARK") + elif event_type == EVENT_PLAY: + print("Event: PLAY") + elif event_type == EVENT_END: + print("Event: END") + elif event_type == EVENT_MSG_TERMINATED: + print("Event: MSG_TERMINATED") + else: + print(f"Unknown event type: {event_type}") i += 1 return 0 - samplerate = Initialize(output=AUDIO_OUTPUT_PLAYBACK) + samplerate = Initialize(output=AUDIO_OUTPUT_RETRIEVAL) SetSynthCallback(synth_cb) s = "This is a test, only a test. " uid = c_uint(0) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 17a4f75..4fc6283 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -34,7 +34,8 @@ def __init__(self, proxy): EspeakDriver._defaultVoice = "default" EspeakDriver._moduleInitialized = True self._proxy = proxy - self._looping = False + print("espeak init setting looping to false..") + self._proxy._looping = False self._stopping = False self._speaking = False self._text_to_say = None @@ -59,6 +60,7 @@ def destroy(): _espeak.SetSynthCallback(None) def stop(self): + print("EspeakDriver.stop called, setting _stopping to True") if _espeak.IsPlaying(): self._stopping = True _espeak.Cancel() @@ -167,53 +169,17 @@ def _onSynth(self, wav, numsamples, events): location=event.text_position, length=event.length, ) - elif event.type == _espeak.EVENT_MSG_TERMINATED: - # Final event indicating synthesis completion - if self._save_file: - try: - with wave.open(self._save_file, "wb") as f: - f.setnchannels(1) # Mono - f.setsampwidth(2) # 16-bit samples - f.setframerate(22050) # 22,050 Hz sample rate - f.writeframes(self._data_buffer) - print(f"Audio saved to {self._save_file}") - except Exception as e: - raise RuntimeError(f"Error saving WAV file: {e}") - else: - try: - with NamedTemporaryFile( - suffix=".wav", delete=False - ) as temp_wav: - with wave.open(temp_wav, "wb") as f: - f.setnchannels(1) # Mono - f.setsampwidth(2) # 16-bit samples - f.setframerate(22050) # 22,050 Hz sample rate - f.writeframes(self._data_buffer) - - temp_wav_name = temp_wav.name - temp_wav.flush() - - # Playback functionality (for say method) - if platform.system() == "Darwin": # macOS - subprocess.run(["afplay", temp_wav_name], check=True) - elif platform.system() == "Linux": - os.system(f"aplay {temp_wav_name} -q") - elif platform.system() == "Windows": - winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) - - # Remove the file after playback - os.remove(temp_wav_name) - except Exception as e: - print(f"Playback error: {e}") - - # Clear the buffer and mark as finished - self._data_buffer = b"" - self._speaking = False - self._proxy.notify("finished-utterance", completed=True) - self._proxy.setBusy(False) - self.endLoop() + print("EVENT_MSG_TERMINATED detected, ending loop.") + # Ensure the loop stops when synthesis completes + self._proxy._looping = False + if not self._is_external_loop: + self.endLoop() # End loop only if not in an external loop break + elif event.type == _espeak.EVENT_END: + print("EVENT_END detected.") + # Optional: handle end of an utterance if applicable + pass i += 1 @@ -226,31 +192,50 @@ def _onSynth(self, wav, numsamples, events): return 0 def endLoop(self): - self._looping = False - - def startLoop(self): - first = True - self._looping = True - while self._looping: - if not self._looping: + print("Ending loop...") + print("EspeakDriver.endLoop called, setting looping to False") + self._proxy._looping = False + + def startLoop(self, external=False): + print(f"EspeakDriver: Entering startLoop (external={external})") + self._proxy._looping = True + self._is_external_loop = external # Track if it's an external loop + timeout = time.time() + 10 + while self._proxy._looping: + if time.time() > timeout: + print("Exiting startLoop due to timeout.") + self._proxy._looping = False + break + print("EspeakDriver loop iteration") + if self._text_to_say: + self._start_synthesis(self._text_to_say) + try: + next(self.iterate()) + except StopIteration: + print("StopIteration in startLoop") break - if first: - self._proxy.setBusy(False) - first = False - if self._text_to_say: - self._start_synthesis(self._text_to_say) - self.iterate() time.sleep(0.01) + print("EspeakDriver: Exiting startLoop") + def iterate(self): - if not self._looping: + print("running espeak iterate once") + if not self._proxy._looping: + print("Not looping, returning from iterate...") return if self._stopping: _espeak.Cancel() + print("Exiting iterate due to stop.") self._stopping = False self._proxy.notify("finished-utterance", completed=False) self._proxy.setBusy(False) + self._proxy._looping = False # Mark the loop as done + return + + # Only call endLoop in an internal loop, leave external control to external loop handler + if not self._is_external_loop: self.endLoop() + yield # Yield back to `startLoop` def say(self, text): self._text_to_say = text diff --git a/test.py b/test.py new file mode 100644 index 0000000..9bd333e --- /dev/null +++ b/test.py @@ -0,0 +1,76 @@ +import time +import ctypes +from unittest import mock + +import pyttsx3 + +# Initialize the pyttsx3 engine +engine = pyttsx3.init("espeak") + + +# Set up event listeners for debugging +def on_start(name): + print(f"[DEBUG] Started utterance: {name}") + print(f"Started utterance: {name}") + + +def on_word(name, location, length): + print(f"Word: {name} at {location} with length {length}") + # Interrupt the utterance if location is above a threshold to simulate test_interrupting_utterance + if location > 10: + print("Interrupting utterance by calling endLoop...") + engine.endLoop() # Directly call endLoop instead of stop + + +def on_end(name, completed): + print(f"Finished utterance: {name}, completed: {completed}") + + +# Connect the listeners +engine.connect("started-utterance", on_start) +engine.connect("started-word", on_word) +engine.connect("finished-utterance", on_end) + + +# Demo for test_interrupting_utterance +def demo_interrupting_utterance(): + print("\nRunning demo_interrupting_utterance...") + engine.say("The quick brown fox jumped over the lazy dog.") + engine.runAndWait() + engine.endLoop() + + +# Demo for test_external_event_loop +def demo_external_event_loop(): + print("\nRunning demo_external_event_loop...") + + def external_loop(): + # Simulate external loop iterations + for _ in range(5): + engine.iterate() # Process engine events + time.sleep(0.5) # Adjust timing as needed + + engine.say("The quick brown fox jumped over the lazy dog.") + engine.startLoop(False) # Start loop without blocking + external_loop() + engine.endLoop() # End the event loop explicitly + + +# Run demos +demo_interrupting_utterance() +from pyinstrument import Profiler + +# Initialize the profiler +profiler = Profiler() + +# Start profiling +profiler.start() + +# Run the external event loop demo +demo_external_event_loop() + +# Stop profiling +profiler.stop() + +# Print the profiling report +profiler.print() From d59af8ed502148705e02ddbf07ce96d3eb481c5e Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 05:52:16 +0000 Subject: [PATCH 02/39] resetting test code as expected --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index 9bd333e..15e6f6b 100644 --- a/test.py +++ b/test.py @@ -19,7 +19,7 @@ def on_word(name, location, length): # Interrupt the utterance if location is above a threshold to simulate test_interrupting_utterance if location > 10: print("Interrupting utterance by calling endLoop...") - engine.endLoop() # Directly call endLoop instead of stop + engine.stop() # Directly call endLoop instead of stop def on_end(name, completed): From c5d231cdeee7b2b1d6c344d9fe34d1bad75bf26d Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 07:08:21 +0000 Subject: [PATCH 03/39] reworking to minimise changes in driver proxy --- pyttsx3/driver.py | 10 +-- pyttsx3/drivers/espeak.py | 129 +++++++++++++++++++++++++------------- test.py | 2 +- 3 files changed, 92 insertions(+), 49 deletions(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index b493eb8..423eee7 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -49,7 +49,6 @@ def __init__(self, engine, driverName, debug): self._engine = engine self._queue = [] self._busy = True - self._looping = False self._name = None self._iterator = None self._debug = debug @@ -194,8 +193,6 @@ def startLoop(self, useDriverLoop): """ Called by the engine to start an event loop. """ - print("driver.startLoop setting looping to true..") - self._looping = True if useDriverLoop: self._driver.startLoop() else: @@ -205,13 +202,16 @@ def endLoop(self, useDriverLoop): """ Called by the engine to stop an event loop. """ - print("DriverProxy.endLoop called, setting looping to False") - self._looping = False + print("DriverProxy.endLoop called; useDriverLoop:", useDriverLoop) self._queue = [] self._driver.stop() if useDriverLoop: + print("DriverProxy.endLoop calling driver.endLoop") self._driver.endLoop() else: + print( + "DriverProxy.endLoop; not calling driver.endLoop, setting iterator to None" + ) self._iterator = None self.setBusy(True) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 4fc6283..7511793 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -34,8 +34,7 @@ def __init__(self, proxy): EspeakDriver._defaultVoice = "default" EspeakDriver._moduleInitialized = True self._proxy = proxy - print("espeak init setting looping to false..") - self._proxy._looping = False + self._looping = False self._stopping = False self._speaking = False self._text_to_say = None @@ -60,10 +59,14 @@ def destroy(): _espeak.SetSynthCallback(None) def stop(self): - print("EspeakDriver.stop called, setting _stopping to True") - if _espeak.IsPlaying(): - self._stopping = True - _espeak.Cancel() + if not self._stopping: + print("[DEBUG] EspeakDriver.stop called") + if self._looping: + self._stopping = True + self._looping = False + if _espeak.IsPlaying(): + _espeak.Cancel() + self._proxy.setBusy(False) @staticmethod def getProperty(name: str): @@ -169,17 +172,54 @@ def _onSynth(self, wav, numsamples, events): location=event.text_position, length=event.length, ) + elif event.type == _espeak.EVENT_MSG_TERMINATED: - print("EVENT_MSG_TERMINATED detected, ending loop.") - # Ensure the loop stops when synthesis completes - self._proxy._looping = False + # Final event indicating synthesis completion + if self._save_file: + try: + with wave.open(self._save_file, "wb") as f: + f.setnchannels(1) # Mono + f.setsampwidth(2) # 16-bit samples + f.setframerate(22050) # 22,050 Hz sample rate + f.writeframes(self._data_buffer) + print(f"Audio saved to {self._save_file}") + except Exception as e: + raise RuntimeError(f"Error saving WAV file: {e}") + else: + try: + with NamedTemporaryFile( + suffix=".wav", delete=False + ) as temp_wav: + with wave.open(temp_wav, "wb") as f: + f.setnchannels(1) # Mono + f.setsampwidth(2) # 16-bit samples + f.setframerate(22050) # 22,050 Hz sample rate + f.writeframes(self._data_buffer) + + temp_wav_name = temp_wav.name + temp_wav.flush() + + # Playback functionality (for say method) + if platform.system() == "Darwin": # macOS + subprocess.run(["afplay", temp_wav_name], check=True) + elif platform.system() == "Linux": + os.system(f"aplay {temp_wav_name} -q") + elif platform.system() == "Windows": + winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) + + # Remove the file after playback + os.remove(temp_wav_name) + except Exception as e: + print(f"Playback error: {e}") + + # Clear the buffer and mark as finished + self._data_buffer = b"" + self._speaking = False + self._proxy.notify("finished-utterance", completed=True) + self._proxy.setBusy(False) if not self._is_external_loop: - self.endLoop() # End loop only if not in an external loop + self.endLoop() break - elif event.type == _espeak.EVENT_END: - print("EVENT_END detected.") - # Optional: handle end of an utterance if applicable - pass i += 1 @@ -192,50 +232,53 @@ def _onSynth(self, wav, numsamples, events): return 0 def endLoop(self): - print("Ending loop...") - print("EspeakDriver.endLoop called, setting looping to False") - self._proxy._looping = False + print("endLoop called; external:", self._is_external_loop) + self._looping = False def startLoop(self, external=False): - print(f"EspeakDriver: Entering startLoop (external={external})") - self._proxy._looping = True - self._is_external_loop = external # Track if it's an external loop - timeout = time.time() + 10 - while self._proxy._looping: - if time.time() > timeout: - print("Exiting startLoop due to timeout.") - self._proxy._looping = False + first = True + self._looping = True + self._is_external_loop = external + if external: + self._iterator = self.iterate() or iter([]) + + while self._looping: + if not self._looping: + print("Exiting loop") break - print("EspeakDriver loop iteration") - if self._text_to_say: - self._start_synthesis(self._text_to_say) + if first: + print("Starting loop on first") + self._proxy.setBusy(False) + first = False + if self._text_to_say: + print("Synthesizing text on first") + self._start_synthesis(self._text_to_say) + self._text_to_say = None # Avoid re-synthesizing + try: - next(self.iterate()) + if not external: + print("Iterating on not external") + next(self.iterate()) + time.sleep(0.01) except StopIteration: - print("StopIteration in startLoop") break - time.sleep(0.01) - - print("EspeakDriver: Exiting startLoop") def iterate(self): - print("running espeak iterate once") - if not self._proxy._looping: - print("Not looping, returning from iterate...") + """Process events within an external loop context.""" + if not self._looping: return + if self._stopping: + # Cancel the current utterance if stopping _espeak.Cancel() - print("Exiting iterate due to stop.") self._stopping = False self._proxy.notify("finished-utterance", completed=False) self._proxy.setBusy(False) - self._proxy._looping = False # Mark the loop as done - return + self.endLoop() # Set `_looping` to False, signaling exit - # Only call endLoop in an internal loop, leave external control to external loop handler - if not self._is_external_loop: - self.endLoop() - yield # Yield back to `startLoop` + # Yield only if in an external loop to hand control back + if self._is_external_loop: + yield def say(self, text): self._text_to_say = text diff --git a/test.py b/test.py index 15e6f6b..0699c21 100644 --- a/test.py +++ b/test.py @@ -37,7 +37,6 @@ def demo_interrupting_utterance(): print("\nRunning demo_interrupting_utterance...") engine.say("The quick brown fox jumped over the lazy dog.") engine.runAndWait() - engine.endLoop() # Demo for test_external_event_loop @@ -53,6 +52,7 @@ def external_loop(): engine.say("The quick brown fox jumped over the lazy dog.") engine.startLoop(False) # Start loop without blocking external_loop() + print("Calling endLoop from external demo...") engine.endLoop() # End the event loop explicitly From 414551efad8a03f1670aadda648a69471c073053 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 12:42:19 +0000 Subject: [PATCH 04/39] working on the test code now --- pyttsx3/driver.py | 49 +++++++++++++++++++----- pyttsx3/drivers/_espeak.py | 52 ++++++++++++------------- pyttsx3/drivers/espeak.py | 77 +++++++++++++++++++++----------------- pyttsx3/drivers/sapi5.py | 3 +- pyttsx3/engine.py | 10 ++++- test.py | 34 ++++++++++++----- 6 files changed, 141 insertions(+), 84 deletions(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index 423eee7..bbf2481 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -2,6 +2,7 @@ import traceback import weakref import importlib +import time class DriverProxy(object): @@ -109,13 +110,15 @@ def setBusy(self, busy): @param busy: True when busy, false when idle @type busy: bool """ + if self._busy != busy: + print(f"[DEBUG] Transitioning to {'busy' if busy else 'idle'} state.") self._busy = busy - if not self._busy: + if not busy: self._pump() def isBusy(self): """ - @return: True if the driver is busy, false if not + @return: True if the driver is busy, False if not @rtype: bool """ return self._busy @@ -181,13 +184,41 @@ def setProperty(self, name, value): """ self._push(self._driver.setProperty, (name, value)) - def runAndWait(self): + def runAndWait(self, timeout=0.01): """ - Called by the engine to start an event loop, process all commands in - the queue at the start of the loop, and then exit the loop. + Runs an event loop until the queue is empty or the timeout is reached. """ + # First, check if the loop is already running + if self._driver._looping: + print("[DEBUG] Loop already active; waiting for completion.") + start_time = time.time() + while self._driver._looping and (time.time() - start_time < timeout): + time.sleep(0.1) + if self._driver._looping: + print("[WARNING] Forcing loop exit due to timeout.") + self._driver.endLoop() + self.setBusy(False) + + # Push endLoop to the queue to complete the sequence self._push(self._engine.endLoop, tuple()) - self._driver.startLoop() + + # Start the loop if not already running + if not self._driver._looping: + self._driver.startLoop() + + # Track the start time for timeout handling + start_time = time.time() + + # Main wait loop to ensure commands are fully processed + while ( + self._driver._queue or self._driver._text_to_say or self._driver._speaking + ): + if time.time() - start_time > timeout: + print("[WARNING] runAndWait timeout reached.") + break + time.sleep(0.1) # Allow time for the loop to process items in the queue + + print("[DEBUG] runAndWait completed.") def startLoop(self, useDriverLoop): """ @@ -209,11 +240,9 @@ def endLoop(self, useDriverLoop): print("DriverProxy.endLoop calling driver.endLoop") self._driver.endLoop() else: - print( - "DriverProxy.endLoop; not calling driver.endLoop, setting iterator to None" - ) self._iterator = None - self.setBusy(True) + # Set driver as not busy after loop finishes + self.setBusy(False) def iterate(self): """ diff --git a/pyttsx3/drivers/_espeak.py b/pyttsx3/drivers/_espeak.py index ad0c76a..06e7ebd 100644 --- a/pyttsx3/drivers/_espeak.py +++ b/pyttsx3/drivers/_espeak.py @@ -61,7 +61,7 @@ def load_library(): raise RuntimeError( "This means you probably do not have eSpeak or eSpeak-ng installed!" ) -except Exception as exp: +except Exception: raise # constants and such from speak_lib.h @@ -112,7 +112,7 @@ class EVENT(Structure): ("option", c_int, 1, 0), ) Initialize.__doc__ = """Must be called before any synthesis functions are called. - output: the audio data can either be played by eSpeak or passed back by the SynthCallback function. + output: the audio data can either be played by eSpeak or passed back by the SynthCallback function. buflength: The length in mS of sound buffers passed to the SynthCallback function. path: The directory which contains the espeak-data directory, or NULL for the default location. options: bit 0: 1=allow espeakEVENT_PHONEME events. @@ -135,7 +135,7 @@ def SetSynthCallback(cb): SetSynthCallback.__doc__ = """Must be called before any synthesis functions are called. This specifies a function in the calling program which is called when a buffer of - speech sound data has been produced. + speech sound data has been produced. The callback function is of the form: @@ -254,7 +254,7 @@ def Synth( start of the text. position_type: Determines whether "position" is a number of characters, words, or sentences. - Values: + Values: end_position: If set, this gives a character position at which speaking will stop. A value of zero indicates no end position. @@ -273,13 +273,13 @@ def Synth( espeak.ENDPAUSE If set then a sentence pause is added at the end of the text. If not set then this pause is suppressed. - unique_identifier: message identifier; helpful for identifying later + unique_identifier: message identifier; helpful for identifying later data supplied to the callback. user_data: pointer which will be passed to the callback function. - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" @@ -308,25 +308,25 @@ def Synth_Mark(text, index_mark, end_position=0, flags=CHARS_AUTO): For the other parameters, see espeak_Synth() - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" Key = cfunc("espeak_Key", dll, c_int, ("key_name", c_char_p, 1)) Key.__doc__ = """Speak the name of a keyboard key. - Currently this just speaks the "key_name" as given + Currently this just speaks the "key_name" as given - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" Char = cfunc("espeak_Char", dll, c_int, ("character", c_wchar, 1)) -Char.__doc__ = """Speak the name of the given character +Char.__doc__ = """Speak the name of the given character - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" @@ -367,7 +367,7 @@ def Synth_Mark(text, index_mark, end_position=0, flags=CHARS_AUTO): espeak.RANGE: pitch range, range 0-100. 0-monotone, 50=normal espeak.PUNCTUATION: which punctuation characters to announce: - value in espeak_PUNCT_TYPE (none, all, some), + value in espeak_PUNCT_TYPE (none, all, some), see espeak_GetParameter() to specify which characters are announced. espeak.CAPITALS: announce capital letters by: @@ -377,8 +377,8 @@ def Synth_Mark(text, index_mark, end_position=0, flags=CHARS_AUTO): 3 or higher, by raising pitch. This values gives the amount in Hz by which the pitch of a word raised to indicate it has a capital letter. - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" @@ -389,13 +389,13 @@ def Synth_Mark(text, index_mark, end_position=0, flags=CHARS_AUTO): SetPunctuationList = cfunc( "espeak_SetPunctuationList", dll, c_int, ("punctlist", c_wchar, 1) ) -SetPunctuationList.__doc__ = """Specified a list of punctuation characters whose names are +SetPunctuationList.__doc__ = """Specified a list of punctuation characters whose names are to be spoken when the value of the Punctuation parameter is set to "some". punctlist: A list of character codes, terminated by a zero character. - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" @@ -471,8 +471,8 @@ def ListVoices(voice_spec=None): SetVoiceByName.__doc__ = """Searches for a voice with a matching "name" field. Language is not considered. "name" is a UTF8 string. - Return: EE_OK: operation achieved - EE_BUFFER_FULL: the command can not be buffered; + Return: EE_OK: operation achieved + EE_BUFFER_FULL: the command can not be buffered; you may try after a while to call the function again. EE_INTERNAL_ERROR.""" @@ -507,7 +507,7 @@ def ListVoices(voice_spec=None): function returns, the audio output is fully stopped and the synthesizer is ready to synthesize a new message. - Return: EE_OK: operation achieved + Return: EE_OK: operation achieved EE_INTERNAL_ERROR.""" IsPlaying = cfunc("espeak_IsPlaying", dll, c_int) @@ -515,12 +515,12 @@ def ListVoices(voice_spec=None): Synchronize = cfunc("espeak_Synchronize", dll, c_int) Synchronize.__doc__ = """This function returns when all data have been spoken. - Return: EE_OK: operation achieved + Return: EE_OK: operation achieved EE_INTERNAL_ERROR.""" Terminate = cfunc("espeak_Terminate", dll, c_int) Terminate.__doc__ = """last function to be called. - Return: EE_OK: operation achieved + Return: EE_OK: operation achieved EE_INTERNAL_ERROR.""" Info = cfunc("espeak_Info", dll, c_char_p, ("ptr", c_void_p, 1, 0)) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 7511793..92a6d79 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -38,6 +38,7 @@ def __init__(self, proxy): self._stopping = False self._speaking = False self._text_to_say = None + self._queue = [] # self._data_buffer = b"" self._numerise_buffer = [] self._save_file = None @@ -81,7 +82,7 @@ def getProperty(name: str): "utf-8", errors="ignore" ) kwargs["languages"] = [language_code] - except UnicodeDecodeError as e: + except UnicodeDecodeError: kwargs["languages"] = ["Unknown"] genders = [None, "male", "female"] kwargs["gender"] = genders[v.gender] @@ -139,10 +140,10 @@ def _start_synthesis(self, text): self._proxy.setBusy(True) self._proxy.notify("started-utterance") self._speaking = True - self._data_buffer = b"" # Ensure buffer is cleared before starting + self._data_buffer = b"" try: _espeak.Synth( - str(text).encode("utf-8"), flags=_espeak.ENDPAUSE | _espeak.CHARS_UTF8 + text.encode("utf-8"), flags=_espeak.ENDPAUSE | _espeak.CHARS_UTF8 ) except Exception as e: self._proxy.setBusy(False) @@ -153,13 +154,15 @@ def _onSynth(self, wav, numsamples, events): if not self._speaking: return 0 - # Process each event in the current callback i = 0 while True: event = events[i] + if event.type == _espeak.EVENT_LIST_TERMINATED: break + elif event.type == _espeak.EVENT_WORD: + # Handle word events to notify on each word spoken if self._text_to_say: start_index = event.text_position - 1 end_index = start_index + event.length @@ -174,46 +177,48 @@ def _onSynth(self, wav, numsamples, events): ) elif event.type == _espeak.EVENT_MSG_TERMINATED: - # Final event indicating synthesis completion + # Handle utterance completion if self._save_file: + # Save audio to file if requested try: with wave.open(self._save_file, "wb") as f: - f.setnchannels(1) # Mono - f.setsampwidth(2) # 16-bit samples - f.setframerate(22050) # 22,050 Hz sample rate + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(22050) f.writeframes(self._data_buffer) print(f"Audio saved to {self._save_file}") except Exception as e: raise RuntimeError(f"Error saving WAV file: {e}") else: + # Playback temporary file (if not saving to file) try: with NamedTemporaryFile( suffix=".wav", delete=False ) as temp_wav: with wave.open(temp_wav, "wb") as f: - f.setnchannels(1) # Mono - f.setsampwidth(2) # 16-bit samples - f.setframerate(22050) # 22,050 Hz sample rate + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(22050) f.writeframes(self._data_buffer) temp_wav_name = temp_wav.name temp_wav.flush() - # Playback functionality (for say method) - if platform.system() == "Darwin": # macOS + if platform.system() == "Darwin": subprocess.run(["afplay", temp_wav_name], check=True) elif platform.system() == "Linux": os.system(f"aplay {temp_wav_name} -q") elif platform.system() == "Windows": winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) - # Remove the file after playback os.remove(temp_wav_name) except Exception as e: print(f"Playback error: {e}") - # Clear the buffer and mark as finished - self._data_buffer = b"" + print( + "[DEBUG] Utterance complete; resetting text_to_say and speaking flag." + ) + self._text_to_say = None # Clear text once utterance completes self._speaking = False self._proxy.notify("finished-utterance", completed=True) self._proxy.setBusy(False) @@ -232,36 +237,36 @@ def _onSynth(self, wav, numsamples, events): return 0 def endLoop(self): - print("endLoop called; external:", self._is_external_loop) - self._looping = False + """End the loop only when there’s no more text to say.""" + if self._queue or self._text_to_say: + print( + "EndLoop called, but queue or text_to_say is not empty; continuing..." + ) + return # Keep looping if there’s still text + else: + print("EndLoop called; stopping loop.") + self._looping = False + self._proxy.setBusy(False) def startLoop(self, external=False): - first = True + """Start the synthesis loop.""" + print("Starting loop") self._looping = True self._is_external_loop = external - if external: - self._iterator = self.iterate() or iter([]) while self._looping: - if not self._looping: - print("Exiting loop") - break - if first: - print("Starting loop on first") - self._proxy.setBusy(False) - first = False - if self._text_to_say: - print("Synthesizing text on first") - self._start_synthesis(self._text_to_say) - self._text_to_say = None # Avoid re-synthesizing + if not self._speaking and self._queue: + self._text_to_say = self._queue.pop(0) + print(f"Synthesizing text: {self._text_to_say}") + self._start_synthesis(self._text_to_say) try: if not external: - print("Iterating on not external") next(self.iterate()) time.sleep(0.01) except StopIteration: break + self._proxy.setBusy(False) def iterate(self): """Process events within an external loop context.""" @@ -281,4 +286,8 @@ def iterate(self): yield def say(self, text): - self._text_to_say = text + print(f"[DEBUG] EspeakDriver.say called with text: {text}") + self._queue.append(text) # Add text to the local queue + if not self._looping: + print("[DEBUG] Starting loop from say") + self.startLoop() diff --git a/pyttsx3/drivers/sapi5.py b/pyttsx3/drivers/sapi5.py index ad1b902..1f8ca11 100644 --- a/pyttsx3/drivers/sapi5.py +++ b/pyttsx3/drivers/sapi5.py @@ -178,8 +178,7 @@ def _ISpeechVoiceEvents_EndStream(self, stream_number, stream_position): d.endLoop() # hangs if you dont have this def _ISpeechVoiceEvents_Word(self, stream_number, stream_position, char, length): - current_text = self._driver._current_text - if current_text: + if current_text := self._driver._current_text: current_word = current_text[char : char + length] else: current_word = "Unknown" diff --git a/pyttsx3/engine.py b/pyttsx3/engine.py index 0643939..4c36e86 100644 --- a/pyttsx3/engine.py +++ b/pyttsx3/engine.py @@ -97,7 +97,7 @@ def say(self, text, name=None): notifications about this utterance. @type name: str """ - if text == None: + if text is None: return "Argument value can't be none or empty" else: self.proxy.say(text, name) @@ -180,7 +180,12 @@ def runAndWait(self): raise RuntimeError("run loop already started") self._inLoop = True self._driverLoop = True - self.proxy.runAndWait() + try: + self.proxy.runAndWait() + finally: + # Ensure cleanup if `runAndWait` completes or encounters an error + self._inLoop = False + self._driverLoop = False def startLoop(self, useDriverLoop=True): """ @@ -208,6 +213,7 @@ def endLoop(self): raise RuntimeError("run loop not started") self.proxy.endLoop(self._driverLoop) self._inLoop = False + self._driverLoop = False # Reset `_driverLoop` on end def iterate(self): """ diff --git a/test.py b/test.py index 0699c21..e17f246 100644 --- a/test.py +++ b/test.py @@ -1,8 +1,6 @@ -import time -import ctypes -from unittest import mock - import pyttsx3 +from pyinstrument import Profiler +import time # Initialize the pyttsx3 engine engine = pyttsx3.init("espeak") @@ -27,9 +25,9 @@ def on_end(name, completed): # Connect the listeners -engine.connect("started-utterance", on_start) -engine.connect("started-word", on_word) -engine.connect("finished-utterance", on_end) +# engine.connect("started-utterance", on_start) +# engine.connect("started-word", on_word) +# engine.connect("finished-utterance", on_end) # Demo for test_interrupting_utterance @@ -56,14 +54,28 @@ def external_loop(): engine.endLoop() # End the event loop explicitly +# Demo for testing multiple `say` calls followed by `runAndWait` +def demo_multiple_say_calls(): + print("\nRunning demo_multiple_say_calls...") + engine.say("The first sentence.") + engine.runAndWait() # Should speak "The first sentence." + + print("Calling say after the first runAndWait()...") + engine.say("The second sentence follows immediately.") + engine.runAndWait() # Should speak "The second sentence follows immediately." + + print("Calling say after the second runAndWait()...") + engine.say("Finally, the third sentence is spoken.") + engine.runAndWait() # Should speak "Finally, the third sentence is spoken." + + # Run demos demo_interrupting_utterance() -from pyinstrument import Profiler # Initialize the profiler profiler = Profiler() -# Start profiling +# Start profiling for the external event loop demo profiler.start() # Run the external event loop demo @@ -71,6 +83,8 @@ def external_loop(): # Stop profiling profiler.stop() - # Print the profiling report profiler.print() + +# Run the multiple `say` calls demo +demo_multiple_say_calls() From a5c83a594e30c68d9d2acf66571f24386f833843 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 12:50:04 +0000 Subject: [PATCH 05/39] remove test.py from repo --- test.py | 90 --------------------------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 test.py diff --git a/test.py b/test.py deleted file mode 100644 index e17f246..0000000 --- a/test.py +++ /dev/null @@ -1,90 +0,0 @@ -import pyttsx3 -from pyinstrument import Profiler -import time - -# Initialize the pyttsx3 engine -engine = pyttsx3.init("espeak") - - -# Set up event listeners for debugging -def on_start(name): - print(f"[DEBUG] Started utterance: {name}") - print(f"Started utterance: {name}") - - -def on_word(name, location, length): - print(f"Word: {name} at {location} with length {length}") - # Interrupt the utterance if location is above a threshold to simulate test_interrupting_utterance - if location > 10: - print("Interrupting utterance by calling endLoop...") - engine.stop() # Directly call endLoop instead of stop - - -def on_end(name, completed): - print(f"Finished utterance: {name}, completed: {completed}") - - -# Connect the listeners -# engine.connect("started-utterance", on_start) -# engine.connect("started-word", on_word) -# engine.connect("finished-utterance", on_end) - - -# Demo for test_interrupting_utterance -def demo_interrupting_utterance(): - print("\nRunning demo_interrupting_utterance...") - engine.say("The quick brown fox jumped over the lazy dog.") - engine.runAndWait() - - -# Demo for test_external_event_loop -def demo_external_event_loop(): - print("\nRunning demo_external_event_loop...") - - def external_loop(): - # Simulate external loop iterations - for _ in range(5): - engine.iterate() # Process engine events - time.sleep(0.5) # Adjust timing as needed - - engine.say("The quick brown fox jumped over the lazy dog.") - engine.startLoop(False) # Start loop without blocking - external_loop() - print("Calling endLoop from external demo...") - engine.endLoop() # End the event loop explicitly - - -# Demo for testing multiple `say` calls followed by `runAndWait` -def demo_multiple_say_calls(): - print("\nRunning demo_multiple_say_calls...") - engine.say("The first sentence.") - engine.runAndWait() # Should speak "The first sentence." - - print("Calling say after the first runAndWait()...") - engine.say("The second sentence follows immediately.") - engine.runAndWait() # Should speak "The second sentence follows immediately." - - print("Calling say after the second runAndWait()...") - engine.say("Finally, the third sentence is spoken.") - engine.runAndWait() # Should speak "Finally, the third sentence is spoken." - - -# Run demos -demo_interrupting_utterance() - -# Initialize the profiler -profiler = Profiler() - -# Start profiling for the external event loop demo -profiler.start() - -# Run the external event loop demo -demo_external_event_loop() - -# Stop profiling -profiler.stop() -# Print the profiling report -profiler.print() - -# Run the multiple `say` calls demo -demo_multiple_say_calls() From 9dcfabb5310f5be20812b324cf97efa4f310e61b Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 13:31:41 +0000 Subject: [PATCH 06/39] reverting engine py - no real change and import order in driver for conficts --- pyttsx3/driver.py | 2 +- pyttsx3/engine.py | 93 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index bbf2481..56255f3 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -1,7 +1,7 @@ +import importlib import sys import traceback import weakref -import importlib import time diff --git a/pyttsx3/engine.py b/pyttsx3/engine.py index 4c36e86..3c723aa 100644 --- a/pyttsx3/engine.py +++ b/pyttsx3/engine.py @@ -1,7 +1,35 @@ -from . import driver +from __future__ import annotations + +import sys import traceback import weakref +from . import driver + +# https://docs.python.org/3/library/sys.html#sys.platform +# The keys are values of Python sys.platform, the values are tuples of engine names. +# The first engine in the value tuple is the default engine for that platform. +_engines_by_sys_platform = { + "darwin": ("nsss", "espeak"), # NSSpeechSynthesizer (deprecated) + "win32": ("sapi5", "espeak"), +} + + +def engines_by_sys_platform() -> tuple[str]: + """ + Return the names of all TTS engines for the current operating system. + If sys.platform is not in _engines_by_sys_platform, return ("espeak",). + """ + return _engines_by_sys_platform.get(sys.platform, ("espeak",)) + + +def default_engine_by_sys_platform() -> str: + """ + Return the name of the default TTS engine for the current operating system. + The first engine in the value tuple is the default engine for that platform. + """ + return engines_by_sys_platform()[0] + class Engine(object): """ @@ -17,7 +45,7 @@ class Engine(object): @type _debug: bool """ - def __init__(self, driverName=None, debug=False): + def __init__(self, driverName: str | None = None, debug: bool = False): """ Constructs a new TTS engine instance. @@ -27,14 +55,27 @@ def __init__(self, driverName=None, debug=False): @param debug: Debugging output enabled or not @type debug: bool """ - self.proxy = driver.DriverProxy(weakref.proxy(self), driverName, debug) - # initialize other vars + self.driver_name = driverName or default_engine_by_sys_platform() + self.proxy = driver.DriverProxy(weakref.proxy(self), self.driver_name, debug) self._connects = {} - self._inLoop = False - self._driverLoop = True self._debug = debug + self._driverLoop = True + self._inLoop = False + + def __repr__(self) -> str: + """ + repr(pyttsx3.init('nsss')) -> "pyttsx3.engine.Engine('nsss', debug=False)" + """ + module_and_class = f"{self.__class__.__module__}.{self.__class__.__name__}" + return f"{module_and_class}('{self.driver_name}', debug={self._debug})" - def _notify(self, topic, **kwargs): + def __str__(self) -> str: + """ + str(pyttsx3.init('nsss')) -> 'nsss' + """ + return self.driver_name + + def _notify(self, topic: str, **kwargs) -> None: """ Invokes callbacks for an event topic. @@ -50,7 +91,7 @@ def _notify(self, topic, **kwargs): if self._debug: traceback.print_exc() - def connect(self, topic, cb): + def connect(self, topic: str, cb: callable) -> dict: """ Registers a callback for an event topic. Valid topics and their associated values: @@ -71,7 +112,7 @@ def connect(self, topic, cb): arr.append(cb) return {"topic": topic, "cb": cb} - def disconnect(self, token): + def disconnect(self, token: dict) -> None: """ Unregisters a callback for an event topic. @@ -87,7 +128,7 @@ def disconnect(self, token): if len(arr) == 0: del self._connects[topic] - def say(self, text, name=None): + def say(self, text: str | None, name: str | None = None): """ Adds an utterance to speak to the event queue. @@ -97,18 +138,18 @@ def say(self, text, name=None): notifications about this utterance. @type name: str """ - if text is None: - return "Argument value can't be none or empty" - else: + if str(text or "").strip(): self.proxy.say(text, name) + else: + return "Argument value can't be None or empty" - def stop(self): + def stop(self) -> None: """ Stops the current utterance and clears the event queue. """ self.proxy.stop() - def save_to_file(self, text, filename, name=None): + def save_to_file(self, text: str, filename: str, name: str | None = None) -> None: """ Adds an utterance to speak to the event queue. @@ -119,16 +160,17 @@ def save_to_file(self, text, filename, name=None): notifications about this utterance. @type name: str """ + assert text and filename self.proxy.save_to_file(text, filename, name) - def isBusy(self): + def isBusy(self) -> bool: """ @return: True if an utterance is currently being spoken, false if not @rtype: bool """ return self.proxy.isBusy() - def getProperty(self, name): + def getProperty(self, name: str) -> object: """ Gets the current value of a property. Valid names and values include: @@ -146,9 +188,10 @@ def getProperty(self, name): @rtype: object @raise KeyError: When the property name is unknown """ + assert name return self.proxy.getProperty(name) - def setProperty(self, name, value): + def setProperty(self, name: str, value: str | float) -> None: """ Adds a property value to set to the event queue. Valid names and values include: @@ -168,7 +211,7 @@ def setProperty(self, name, value): """ self.proxy.setProperty(name, value) - def runAndWait(self): + def runAndWait(self) -> None: """ Runs an event loop until all commands queued up until this method call complete. Blocks during the event loop and returns when the queue is @@ -180,14 +223,9 @@ def runAndWait(self): raise RuntimeError("run loop already started") self._inLoop = True self._driverLoop = True - try: - self.proxy.runAndWait() - finally: - # Ensure cleanup if `runAndWait` completes or encounters an error - self._inLoop = False - self._driverLoop = False + self.proxy.runAndWait() - def startLoop(self, useDriverLoop=True): + def startLoop(self, useDriverLoop: bool = True) -> None: """ Starts an event loop to process queued commands and callbacks. @@ -203,7 +241,7 @@ def startLoop(self, useDriverLoop=True): self._driverLoop = useDriverLoop self.proxy.startLoop(self._driverLoop) - def endLoop(self): + def endLoop(self) -> None: """ Stops a running event loop. @@ -213,7 +251,6 @@ def endLoop(self): raise RuntimeError("run loop not started") self.proxy.endLoop(self._driverLoop) self._inLoop = False - self._driverLoop = False # Reset `_driverLoop` on end def iterate(self): """ From 84b9af60262f2307997580f71ca880e733cc04e5 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 13:39:12 +0000 Subject: [PATCH 07/39] remove sys --- pyttsx3/driver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index 3dc2e9d..386f9ec 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -1,5 +1,4 @@ import importlib -import sys import traceback import weakref import time From ba57098b3429401e25c23b440939e923901417c0 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 23:34:26 +0000 Subject: [PATCH 08/39] switching print lines to logging --- pyttsx3/driver.py | 19 +++++++++++-------- pyttsx3/drivers/espeak.py | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index 386f9ec..a8eb96d 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -1,6 +1,7 @@ import importlib import traceback import weakref +import logging import time @@ -83,7 +84,7 @@ def _pump(self): except Exception as e: self.notify("error", exception=e) if self._debug: - traceback.print_exc() + traceback.logging.debug_exc() def notify(self, topic, **kwargs): """ @@ -106,7 +107,9 @@ def setBusy(self, busy): @type busy: bool """ if self._busy != busy: - print(f"[DEBUG] Transitioning to {'busy' if busy else 'idle'} state.") + logging.debug( + f"[DEBUG] Transitioning to {'busy' if busy else 'idle'} state." + ) self._busy = busy if not busy: self._pump() @@ -185,12 +188,12 @@ def runAndWait(self, timeout=0.01): """ # First, check if the loop is already running if self._driver._looping: - print("[DEBUG] Loop already active; waiting for completion.") + logging.debug("[DEBUG] Loop already active; waiting for completion.") start_time = time.time() while self._driver._looping and (time.time() - start_time < timeout): time.sleep(0.1) if self._driver._looping: - print("[WARNING] Forcing loop exit due to timeout.") + logging.debug("[WARNING] Forcing loop exit due to timeout.") self._driver.endLoop() self.setBusy(False) @@ -209,11 +212,11 @@ def runAndWait(self, timeout=0.01): self._driver._queue or self._driver._text_to_say or self._driver._speaking ): if time.time() - start_time > timeout: - print("[WARNING] runAndWait timeout reached.") + logging.debug("[WARNING] runAndWait timeout reached.") break time.sleep(0.1) # Allow time for the loop to process items in the queue - print("[DEBUG] runAndWait completed.") + logging.debug("[DEBUG] runAndWait completed.") def startLoop(self, useDriverLoop): """ @@ -228,11 +231,11 @@ def endLoop(self, useDriverLoop): """ Called by the engine to stop an event loop. """ - print("DriverProxy.endLoop called; useDriverLoop:", useDriverLoop) + logging.debug(f"DriverProxy.endLoop called; useDriverLoop:: {useDriverLoop}") self._queue = [] self._driver.stop() if useDriverLoop: - print("DriverProxy.endLoop calling driver.endLoop") + logging.debug("DriverProxy.endLoop calling driver.endLoop") self._driver.endLoop() else: self._iterator = None diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 92a6d79..e25bcae 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -5,6 +5,7 @@ import time import subprocess from tempfile import NamedTemporaryFile +import logging if platform.system() == "Windows": import winsound @@ -61,7 +62,7 @@ def destroy(): def stop(self): if not self._stopping: - print("[DEBUG] EspeakDriver.stop called") + logging.debug("[DEBUG] EspeakDriver.stop called") if self._looping: self._stopping = True self._looping = False @@ -186,7 +187,7 @@ def _onSynth(self, wav, numsamples, events): f.setsampwidth(2) f.setframerate(22050) f.writeframes(self._data_buffer) - print(f"Audio saved to {self._save_file}") + logging.debug(f"Audio saved to {self._save_file}") except Exception as e: raise RuntimeError(f"Error saving WAV file: {e}") else: @@ -213,9 +214,9 @@ def _onSynth(self, wav, numsamples, events): os.remove(temp_wav_name) except Exception as e: - print(f"Playback error: {e}") + logging.debug(f"Playback error: {e}") - print( + logging.debug( "[DEBUG] Utterance complete; resetting text_to_say and speaking flag." ) self._text_to_say = None # Clear text once utterance completes @@ -239,25 +240,25 @@ def _onSynth(self, wav, numsamples, events): def endLoop(self): """End the loop only when there’s no more text to say.""" if self._queue or self._text_to_say: - print( + logging.debug( "EndLoop called, but queue or text_to_say is not empty; continuing..." ) return # Keep looping if there’s still text else: - print("EndLoop called; stopping loop.") + logging.debug("EndLoop called; stopping loop.") self._looping = False self._proxy.setBusy(False) def startLoop(self, external=False): """Start the synthesis loop.""" - print("Starting loop") + logging.debug("Starting loop") self._looping = True self._is_external_loop = external while self._looping: if not self._speaking and self._queue: self._text_to_say = self._queue.pop(0) - print(f"Synthesizing text: {self._text_to_say}") + logging.debug(f"Synthesizing text: {self._text_to_say}") self._start_synthesis(self._text_to_say) try: @@ -286,8 +287,8 @@ def iterate(self): yield def say(self, text): - print(f"[DEBUG] EspeakDriver.say called with text: {text}") + logging.debug(f"[DEBUG] EspeakDriver.say called with text: {text}") self._queue.append(text) # Add text to the local queue if not self._looping: - print("[DEBUG] Starting loop from say") + logging.debug("[DEBUG] Starting loop from say") self.startLoop() From 65da48f3c081c8f13769c7413488780840a279c7 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 23:37:33 +0000 Subject: [PATCH 09/39] reverting to print traceback --- pyttsx3/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index a8eb96d..f47d0f8 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -84,7 +84,7 @@ def _pump(self): except Exception as e: self.notify("error", exception=e) if self._debug: - traceback.logging.debug_exc() + traceback.print_exc() def notify(self, topic, **kwargs): """ From 656f6385b17598679a303811979e5bcfb90e0d8b Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 4 Nov 2024 23:49:05 +0000 Subject: [PATCH 10/39] putting changes to runAndWait in espeak to keep other engines working as was --- pyttsx3/driver.py | 37 ++++--------------------------------- pyttsx3/drivers/espeak.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/pyttsx3/driver.py b/pyttsx3/driver.py index f47d0f8..a27ff46 100644 --- a/pyttsx3/driver.py +++ b/pyttsx3/driver.py @@ -2,7 +2,6 @@ import traceback import weakref import logging -import time class DriverProxy(object): @@ -182,41 +181,13 @@ def setProperty(self, name, value): """ self._push(self._driver.setProperty, (name, value)) - def runAndWait(self, timeout=0.01): + def runAndWait(self): """ - Runs an event loop until the queue is empty or the timeout is reached. + Called by the engine to start an event loop, process all commands in + the queue at the start of the loop, and then exit the loop. """ - # First, check if the loop is already running - if self._driver._looping: - logging.debug("[DEBUG] Loop already active; waiting for completion.") - start_time = time.time() - while self._driver._looping and (time.time() - start_time < timeout): - time.sleep(0.1) - if self._driver._looping: - logging.debug("[WARNING] Forcing loop exit due to timeout.") - self._driver.endLoop() - self.setBusy(False) - - # Push endLoop to the queue to complete the sequence self._push(self._engine.endLoop, tuple()) - - # Start the loop if not already running - if not self._driver._looping: - self._driver.startLoop() - - # Track the start time for timeout handling - start_time = time.time() - - # Main wait loop to ensure commands are fully processed - while ( - self._driver._queue or self._driver._text_to_say or self._driver._speaking - ): - if time.time() - start_time > timeout: - logging.debug("[WARNING] runAndWait timeout reached.") - break - time.sleep(0.1) # Allow time for the loop to process items in the queue - - logging.debug("[DEBUG] runAndWait completed.") + self._driver.startLoop() def startLoop(self, useDriverLoop): """ diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index e25bcae..a53f7c9 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -292,3 +292,37 @@ def say(self, text): if not self._looping: logging.debug("[DEBUG] Starting loop from say") self.startLoop() + + def runAndWait(self, timeout=0.01): + """ + Runs an event loop until the queue is empty or the timeout is reached. + """ + # First, check if the loop is already running + if self._looping: + logging.debug("[DEBUG] Loop already active; waiting for completion.") + start_time = time.time() + while self._looping and (time.time() - start_time < timeout): + time.sleep(0.1) + if self._looping: + logging.debug("[WARNING] Forcing loop exit due to timeout.") + self.endLoop() + self._proxy.setBusy(False) + + # Push endLoop to the queue to complete the sequence + self._proxy._push(self._proxy._engine.endLoop, tuple()) + + # Start the loop if not already running + if not self._looping: + self.startLoop() + + # Track the start time for timeout handling + start_time = time.time() + + # Main wait loop to ensure commands are fully processed + while self._queue or self._text_to_say or self._speaking: + if time.time() - start_time > timeout: + logging.debug("[WARNING] runAndWait timeout reached.") + break + time.sleep(0.1) # Allow time for the loop to process items in the queue + + logging.debug("[DEBUG] runAndWait completed.") From 2a7933fa1736a6f9cb70c0cf1274ac97516e923e Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 5 Nov 2024 09:06:50 +0000 Subject: [PATCH 11/39] Remove Jeep cut F string Co-authored-by: Christian Clauss --- pyttsx3/drivers/_espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/_espeak.py b/pyttsx3/drivers/_espeak.py index 06e7ebd..71267c8 100644 --- a/pyttsx3/drivers/_espeak.py +++ b/pyttsx3/drivers/_espeak.py @@ -530,7 +530,7 @@ def ListVoices(voice_spec=None): if __name__ == "__main__": def synth_cb(wav, numsample, events): - print(f"Callback received: numsample={numsample}") + print(f"Callback received: {numsample=}") i = 0 while True: event_type = events[i].type From 01ba126d6dfe6e0bf22e71342fdad4c21e5ae383 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 5 Nov 2024 09:07:44 +0000 Subject: [PATCH 12/39] Logger to include filename Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index a53f7c9..64f46d1 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -7,6 +7,8 @@ from tempfile import NamedTemporaryFile import logging +logger = logging.getLogger(__name__) + if platform.system() == "Windows": import winsound From e3d998ce5b5a350aa1e2c065b8886f01f08798f1 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 5 Nov 2024 09:22:00 +0000 Subject: [PATCH 13/39] add back in comments for audio --- pyttsx3/drivers/espeak.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 64f46d1..a04ef3b 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -1,3 +1,6 @@ +from . import _espeak +from ..voice import Voice + import os import wave import platform @@ -12,9 +15,6 @@ if platform.system() == "Windows": import winsound -from . import _espeak -from ..voice import Voice - # noinspection PyPep8Naming def buildDriver(proxy): @@ -185,9 +185,9 @@ def _onSynth(self, wav, numsamples, events): # Save audio to file if requested try: with wave.open(self._save_file, "wb") as f: - f.setnchannels(1) - f.setsampwidth(2) - f.setframerate(22050) + f.setnchannels(1) # Mono + f.setsampwidth(2) # 16-bit samples + f.setframerate(22050) # 22,050 Hz sample rate f.writeframes(self._data_buffer) logging.debug(f"Audio saved to {self._save_file}") except Exception as e: @@ -199,9 +199,9 @@ def _onSynth(self, wav, numsamples, events): suffix=".wav", delete=False ) as temp_wav: with wave.open(temp_wav, "wb") as f: - f.setnchannels(1) - f.setsampwidth(2) - f.setframerate(22050) + f.setnchannels(1) # Mono + f.setsampwidth(2) # 16-bit samples + f.setframerate(22050) # 22,050 Hz sample rate f.writeframes(self._data_buffer) temp_wav_name = temp_wav.name From d8bebdf91589e1dc2d815f0e63be3d96a6f57c6d Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:24:32 +0000 Subject: [PATCH 14/39] fix ruff error --- pyttsx3/drivers/espeak.py | 1 + test.py | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 test.py diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 393b67f..8b3fe96 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -15,6 +15,7 @@ from ..voice import Voice from . import _espeak + # noinspection PyPep8Naming def buildDriver(proxy): return EspeakDriver(proxy) diff --git a/test.py b/test.py new file mode 100644 index 0000000..f3903ee --- /dev/null +++ b/test.py @@ -0,0 +1,90 @@ +import pyttsx3 +from pyinstrument import Profiler +import time + +# Initialize the pyttsx3 engine +engine = pyttsx3.init("espeak") + + +# Set up event listeners for debugging +def on_start(name): + print(f"[DEBUG] Started utterance: {name}") + print(f"Started utterance: {name}") + + +def on_word(name, location, length): + print(f"Word: {name} at {location} with length {length}") + # Interrupt the utterance if location is above a threshold to simulate test_interrupting_utterance + if location > 10: + print("Interrupting utterance by calling endLoop...") + engine.stop() # Directly call endLoop instead of stop + + +def on_end(name, completed): + print(f"Finished utterance: {name}, completed: {completed}") + + +# Connect the listeners +# engine.connect("started-utterance", on_start) +# engine.connect("started-word", on_word) +# engine.connect("finished-utterance", on_end) + + +# Demo for test_interrupting_utterance +def demo_interrupting_utterance(): + print("\nRunning demo_interrupting_utterance...") + engine.say("The quick brown fox jumped over the lazy dog.") + engine.runAndWait() + + +# Demo for test_external_event_loop +def demo_external_event_loop(): + print("\nRunning demo_external_event_loop...") + + def external_loop(): + # Simulate external loop iterations + for _ in range(5): + engine.iterate() # Process engine events + time.sleep(0.5) # Adjust timing as needed + + engine.say("The quick brown fox jumped over the lazy dog.") + engine.startLoop(False) # Start loop without blocking + external_loop() + print("Calling endLoop from external demo...") + engine.endLoop() # End the event loop explicitly + + +# Demo for testing multiple `say` calls followed by `runAndWait` +def demo_multiple_say_calls(): + print("\nRunning demo_multiple_say_calls...") + engine.say("The first sentence.") + engine.runAndWait() # Should speak "The first sentence." + + print("Calling say after the first runAndWait()...") + engine.say("The second sentence follows immediately.") + engine.runAndWait() # Should speak "The second sentence follows immediately." + + print("Calling say after the second runAndWait()...") + engine.say("Finally, the third sentence is spoken.") + engine.runAndWait() # Should speak "Finally, the third sentence is spoken." + + +# Run demos +demo_interrupting_utterance() + +# Initialize the profiler +profiler = Profiler() + +# Start profiling for the external event loop demo +profiler.start() + +# Run the external event loop demo +demo_external_event_loop() + +# Stop profiling +profiler.stop() +# Print the profiling report +profiler.print() + +# Run the multiple `say` calls demo +demo_multiple_say_calls() \ No newline at end of file From 3202784fb45cae0e09bac28efd510a54bd07e100 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:25:39 +0000 Subject: [PATCH 15/39] move import to top --- pyttsx3/drivers/espeak.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 8b3fe96..d39c7ae 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -1,3 +1,6 @@ +from ..voice import Voice +from . import _espeak + import ctypes import os import platform @@ -12,9 +15,6 @@ if platform.system() == "Windows": import winsound -from ..voice import Voice -from . import _espeak - # noinspection PyPep8Naming def buildDriver(proxy): From 5d91c30130158550bb1c35b8251659816fa5dbf2 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:28:47 +0000 Subject: [PATCH 16/39] delete test.py --- test.py | 90 --------------------------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 test.py diff --git a/test.py b/test.py deleted file mode 100644 index f3903ee..0000000 --- a/test.py +++ /dev/null @@ -1,90 +0,0 @@ -import pyttsx3 -from pyinstrument import Profiler -import time - -# Initialize the pyttsx3 engine -engine = pyttsx3.init("espeak") - - -# Set up event listeners for debugging -def on_start(name): - print(f"[DEBUG] Started utterance: {name}") - print(f"Started utterance: {name}") - - -def on_word(name, location, length): - print(f"Word: {name} at {location} with length {length}") - # Interrupt the utterance if location is above a threshold to simulate test_interrupting_utterance - if location > 10: - print("Interrupting utterance by calling endLoop...") - engine.stop() # Directly call endLoop instead of stop - - -def on_end(name, completed): - print(f"Finished utterance: {name}, completed: {completed}") - - -# Connect the listeners -# engine.connect("started-utterance", on_start) -# engine.connect("started-word", on_word) -# engine.connect("finished-utterance", on_end) - - -# Demo for test_interrupting_utterance -def demo_interrupting_utterance(): - print("\nRunning demo_interrupting_utterance...") - engine.say("The quick brown fox jumped over the lazy dog.") - engine.runAndWait() - - -# Demo for test_external_event_loop -def demo_external_event_loop(): - print("\nRunning demo_external_event_loop...") - - def external_loop(): - # Simulate external loop iterations - for _ in range(5): - engine.iterate() # Process engine events - time.sleep(0.5) # Adjust timing as needed - - engine.say("The quick brown fox jumped over the lazy dog.") - engine.startLoop(False) # Start loop without blocking - external_loop() - print("Calling endLoop from external demo...") - engine.endLoop() # End the event loop explicitly - - -# Demo for testing multiple `say` calls followed by `runAndWait` -def demo_multiple_say_calls(): - print("\nRunning demo_multiple_say_calls...") - engine.say("The first sentence.") - engine.runAndWait() # Should speak "The first sentence." - - print("Calling say after the first runAndWait()...") - engine.say("The second sentence follows immediately.") - engine.runAndWait() # Should speak "The second sentence follows immediately." - - print("Calling say after the second runAndWait()...") - engine.say("Finally, the third sentence is spoken.") - engine.runAndWait() # Should speak "Finally, the third sentence is spoken." - - -# Run demos -demo_interrupting_utterance() - -# Initialize the profiler -profiler = Profiler() - -# Start profiling for the external event loop demo -profiler.start() - -# Run the external event loop demo -demo_external_event_loop() - -# Stop profiling -profiler.stop() -# Print the profiling report -profiler.print() - -# Run the multiple `say` calls demo -demo_multiple_say_calls() \ No newline at end of file From 4625b59cf8e5817ceff0a13fd399d29d5ba80076 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:47:06 +0000 Subject: [PATCH 17/39] add alsay-utils to make sure aplay installed --- .github/workflows/python_publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_publish.yml b/.github/workflows/python_publish.yml index 50ea4a2..cc0734a 100644 --- a/.github/workflows/python_publish.yml +++ b/.github/workflows/python_publish.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - if: runner.os == 'Linux' - run: sudo apt-get update -q -q && sudo apt-get install --yes espeak-ng libespeak1 + run: sudo apt-get update -q -q && sudo apt-get install --yes espeak-ng libespeak1 alsa-utils - if: runner.os == 'macOS' run: brew install espeak-ng - name: Download and install eSpeak-NG From ce78d9324eaf2513127162ee0738bcc0560855d9 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:51:56 +0000 Subject: [PATCH 18/39] try using snd-dummy on ci --- .github/workflows/python_publish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python_publish.yml b/.github/workflows/python_publish.yml index cc0734a..450bfec 100644 --- a/.github/workflows/python_publish.yml +++ b/.github/workflows/python_publish.yml @@ -30,7 +30,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - if: runner.os == 'Linux' - run: sudo apt-get update -q -q && sudo apt-get install --yes espeak-ng libespeak1 alsa-utils + run: | + sudo apt-get update -q -q + sudo apt-get install --yes espeak-ng libespeak1 alsa-utils + sudo modprobe snd-dummy - if: runner.os == 'macOS' run: brew install espeak-ng - name: Download and install eSpeak-NG From 497c2b1d3f5b913675d9127ea87f8d12a711f02a Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:57:56 +0000 Subject: [PATCH 19/39] use ffmpeg if aplay not installed --- .github/workflows/python_publish.yml | 3 +-- pyttsx3/drivers/espeak.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python_publish.yml b/.github/workflows/python_publish.yml index 450bfec..18c5a29 100644 --- a/.github/workflows/python_publish.yml +++ b/.github/workflows/python_publish.yml @@ -32,8 +32,7 @@ jobs: - if: runner.os == 'Linux' run: | sudo apt-get update -q -q - sudo apt-get install --yes espeak-ng libespeak1 alsa-utils - sudo modprobe snd-dummy + sudo apt-get install --yes espeak-ng libespeak1 alsa-utils ffmpeg - if: runner.os == 'macOS' run: brew install espeak-ng - name: Download and install eSpeak-NG diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index d39c7ae..671ef95 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -208,7 +208,11 @@ def _onSynth(self, wav, numsamples, events): if platform.system() == "Darwin": subprocess.run(["afplay", temp_wav_name], check=True) elif platform.system() == "Linux": - os.system(f"aplay {temp_wav_name} -q") + try: + subprocess.run(f"aplay {temp_wav_name} -q", shell=True, check=True) + except subprocess.CalledProcessError: + logging.debug("Falling back to ffplay for audio playback.") + subprocess.run(f"ffplay -autoexit -nodisp {temp_wav_name}", shell=True) elif platform.system() == "Windows": winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) From 796d889a4243237f6e03d3999d3e94cbf41697c8 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 14:59:48 +0000 Subject: [PATCH 20/39] ruff formatting fix --- pyttsx3/drivers/espeak.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 671ef95..3d264c5 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -209,10 +209,17 @@ def _onSynth(self, wav, numsamples, events): subprocess.run(["afplay", temp_wav_name], check=True) elif platform.system() == "Linux": try: - subprocess.run(f"aplay {temp_wav_name} -q", shell=True, check=True) + subprocess.run( + f"aplay {temp_wav_name} -q", shell=True, check=True + ) except subprocess.CalledProcessError: - logging.debug("Falling back to ffplay for audio playback.") - subprocess.run(f"ffplay -autoexit -nodisp {temp_wav_name}", shell=True) + logging.debug( + "Falling back to ffplay for audio playback." + ) + subprocess.run( + f"ffplay -autoexit -nodisp {temp_wav_name}", + shell=True, + ) elif platform.system() == "Windows": winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) From c64485f8fd1fcaccfd0d3f74d982250dbf9851c7 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:16:34 +0000 Subject: [PATCH 21/39] add in ffmpeg --- .github/workflows/python_publish.yml | 2 ++ pyttsx3/drivers/espeak.py | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/python_publish.yml b/.github/workflows/python_publish.yml index 18c5a29..6478789 100644 --- a/.github/workflows/python_publish.yml +++ b/.github/workflows/python_publish.yml @@ -46,6 +46,8 @@ jobs: espeak-ng --version - if: runner.os != 'Windows' run: espeak-ng --version + - if: runner.os != 'macOS' + uses: FedericoCarboni/setup-ffmpeg@v3 - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 3d264c5..7c423b0 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -220,6 +220,7 @@ def _onSynth(self, wav, numsamples, events): f"ffplay -autoexit -nodisp {temp_wav_name}", shell=True, ) + elif platform.system() == "Windows": winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) From 753c2d708dbebe79c1fa3b61b001c144d9ffd24e Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:21:02 +0000 Subject: [PATCH 22/39] do a if for ci in aplay --- pyttsx3/drivers/espeak.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 7c423b0..c0e813a 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -208,19 +208,31 @@ def _onSynth(self, wav, numsamples, events): if platform.system() == "Darwin": subprocess.run(["afplay", temp_wav_name], check=True) elif platform.system() == "Linux": - try: - subprocess.run( - f"aplay {temp_wav_name} -q", shell=True, check=True - ) - except subprocess.CalledProcessError: + if "CI" in os.environ: logging.debug( - "Falling back to ffplay for audio playback." + "Running in CI environment; using ffmpeg for silent processing." ) + # Use ffmpeg to process the audio file without playback subprocess.run( - f"ffplay -autoexit -nodisp {temp_wav_name}", + f"ffmpeg -i {temp_wav_name} -f null -", shell=True, + check=True, ) - + else: + try: + subprocess.run( + f"aplay {temp_wav_name} -q", + shell=True, + check=True, + ) + except subprocess.CalledProcessError: + logging.debug( + "Falling back to ffplay for audio playback." + ) + subprocess.run( + f"ffplay -autoexit -nodisp {temp_wav_name}", + shell=True, + ) elif platform.system() == "Windows": winsound.PlaySound(temp_wav_name, winsound.SND_FILENAME) From 6998d4a10d78d08ad4aa8f3d7257b68d69ba4888 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:33:44 +0000 Subject: [PATCH 23/39] sort dependencies --- .github/workflows/python_publish.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/python_publish.yml b/.github/workflows/python_publish.yml index 6478789..eca810b 100644 --- a/.github/workflows/python_publish.yml +++ b/.github/workflows/python_publish.yml @@ -32,7 +32,7 @@ jobs: - if: runner.os == 'Linux' run: | sudo apt-get update -q -q - sudo apt-get install --yes espeak-ng libespeak1 alsa-utils ffmpeg + sudo apt-get install --yes alsa-utils espeak-ng ffmpeg libespeak1 - if: runner.os == 'macOS' run: brew install espeak-ng - name: Download and install eSpeak-NG @@ -46,8 +46,6 @@ jobs: espeak-ng --version - if: runner.os != 'Windows' run: espeak-ng --version - - if: runner.os != 'macOS' - uses: FedericoCarboni/setup-ffmpeg@v3 - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From f5c80a654435e530d81a054c0f1085c6f85c4023 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:35:41 +0000 Subject: [PATCH 24/39] add small delay to see if fixes segmentation fault --- tests/test_pyttsx3.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_pyttsx3.py b/tests/test_pyttsx3.py index 917a97f..0e2728f 100644 --- a/tests/test_pyttsx3.py +++ b/tests/test_pyttsx3.py @@ -2,6 +2,7 @@ import sys import wave +import time from unittest import mock import pytest @@ -137,6 +138,9 @@ def test_saving_to_file(engine, tmp_path): engine.save_to_file("Hello World", str(test_file)) engine.runAndWait() + # Add a small delay to ensure file processing completes + time.sleep(0.1) + # Check if the file was created assert test_file.exists(), "The audio file was not created" From d6b66593d6e554495376db44c5a6e44f0d420d48 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:49:05 +0000 Subject: [PATCH 25/39] revert delay and add conftest --- tests/conftest.py | 14 ++++++++++++++ tests/test_pyttsx3.py | 4 ---- 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3a0921f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import os +import pytest + +def pytest_collection_modifyitems(items): + """Skip tests after `test_changing_volume` if in CI environment.""" + if "CI" not in os.environ: + return # Only apply this logic if in CI + + skip = False # Flag to start skipping after `test_changing_volume` + for item in items: + if "test_changing_volume" in item.nodeid: + skip = True + elif skip: + item.add_marker(pytest.mark.skip(reason="Skipping in CI environment after test_changing_volume")) \ No newline at end of file diff --git a/tests/test_pyttsx3.py b/tests/test_pyttsx3.py index 0e2728f..917a97f 100644 --- a/tests/test_pyttsx3.py +++ b/tests/test_pyttsx3.py @@ -2,7 +2,6 @@ import sys import wave -import time from unittest import mock import pytest @@ -138,9 +137,6 @@ def test_saving_to_file(engine, tmp_path): engine.save_to_file("Hello World", str(test_file)) engine.runAndWait() - # Add a small delay to ensure file processing completes - time.sleep(0.1) - # Check if the file was created assert test_file.exists(), "The audio file was not created" From 49f9883503c179539e96a63cd9cb5cd065ee9f55 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 13 Nov 2024 15:50:19 +0000 Subject: [PATCH 26/39] reformat --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3a0921f..466c0b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import pytest + def pytest_collection_modifyitems(items): """Skip tests after `test_changing_volume` if in CI environment.""" if "CI" not in os.environ: @@ -11,4 +12,8 @@ def pytest_collection_modifyitems(items): if "test_changing_volume" in item.nodeid: skip = True elif skip: - item.add_marker(pytest.mark.skip(reason="Skipping in CI environment after test_changing_volume")) \ No newline at end of file + item.add_marker( + pytest.mark.skip( + reason="Skipping in CI environment after test_changing_volume" + ) + ) From 8d854f85382784fb5ba269b047f71076e7720abe Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:00:58 +0000 Subject: [PATCH 27/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 30ca730..df809fb 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -270,7 +270,7 @@ def _onSynth(self, wav, numsamples, events): except Exception as e: logging.debug(f"Playback error: {e}") - logging.debug( + logger.debug( "[DEBUG] Utterance complete; resetting text_to_say and speaking flag." ) self._text_to_say = None # Clear text once utterance completes From c285353be836754a20531e55b5d507b4a9dba1fc Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:01:10 +0000 Subject: [PATCH 28/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index df809fb..9f99839 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -70,7 +70,7 @@ def destroy(): def stop(self): if not self._stopping: - logging.debug("[DEBUG] EspeakDriver.stop called") + logger.debug("[DEBUG] EspeakDriver.stop called") if self._looping: self._stopping = True self._looping = False From bdf09bde7c589d304cf630496e901b6784ee74a1 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:01:29 +0000 Subject: [PATCH 29/39] logging->logger, remove print Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 9f99839..ad31fb0 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -217,7 +217,7 @@ def _onSynth(self, wav, numsamples, events): f.setsampwidth(2) # 16-bit samples f.setframerate(22050) # 22,050 Hz sample rate f.writeframes(self._data_buffer) - logging.debug(f"Audio saved to {self._save_file}") + logger.debug(f"Audio saved to {self._save_file}") except Exception as e: raise RuntimeError(f"Error saving WAV file: {e}") else: From 3881ea1d2ac06d7e69ef568ec81b13dd522bd2df Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:01:45 +0000 Subject: [PATCH 30/39] logging->logger, remove print Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index ad31fb0..72d16a7 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -268,7 +268,7 @@ def _onSynth(self, wav, numsamples, events): os.remove(temp_wav_name) except Exception as e: - logging.debug(f"Playback error: {e}") + logger.debug(f"Playback error: {e}") logger.debug( "[DEBUG] Utterance complete; resetting text_to_say and speaking flag." From be5d5478353692d8594c00eed20bbfd2157c6936 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:01:57 +0000 Subject: [PATCH 31/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 72d16a7..27326c8 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -239,7 +239,7 @@ def _onSynth(self, wav, numsamples, events): subprocess.run(["afplay", temp_wav_name], check=True) elif platform.system() == "Linux": if "CI" in os.environ: - logging.debug( + logger.debug( "Running in CI environment; using ffmpeg for silent processing." ) # Use ffmpeg to process the audio file without playback From c3b70a0220ccf65cbfd74b504ba046927f0c4e0b Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:02:09 +0000 Subject: [PATCH 32/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 27326c8..ecb5dd3 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -256,7 +256,7 @@ def _onSynth(self, wav, numsamples, events): check=True, ) except subprocess.CalledProcessError: - logging.debug( + logger.debug( "Falling back to ffplay for audio playback." ) subprocess.run( From 269a694c1c8cbd0c22b25e059271007a03f834ae Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:02:30 +0000 Subject: [PATCH 33/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index ecb5dd3..5221529 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -375,8 +375,8 @@ def runAndWait(self, timeout=0.01): # Main wait loop to ensure commands are fully processed while self._queue or self._text_to_say or self._speaking: if time.time() - start_time > timeout: - logging.debug("[WARNING] runAndWait timeout reached.") + logger.debug("[WARNING] runAndWait timeout reached.") break time.sleep(0.1) # Allow time for the loop to process items in the queue - logging.debug("[DEBUG] runAndWait completed.") + logger.debug("[DEBUG] runAndWait completed.") From 488b99eb4ab98544ce30a397ede29be13e7722e2 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:02:41 +0000 Subject: [PATCH 34/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 5221529..b235694 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -294,12 +294,12 @@ def _onSynth(self, wav, numsamples, events): def endLoop(self): """End the loop only when there’s no more text to say.""" if self._queue or self._text_to_say: - logging.debug( + logger.debug( "EndLoop called, but queue or text_to_say is not empty; continuing..." ) return # Keep looping if there’s still text else: - logging.debug("EndLoop called; stopping loop.") + logger.debug("EndLoop called; stopping loop.") self._looping = False self._proxy.setBusy(False) From 72826eb8e2c0e2983d2229437c4d5f9492bfe0e7 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:02:53 +0000 Subject: [PATCH 35/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index b235694..f5de78a 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -305,7 +305,7 @@ def endLoop(self): def startLoop(self, external=False): """Start the synthesis loop.""" - logging.debug("Starting loop") + logger.debug("Starting loop") self._looping = True self._is_external_loop = external From 671b291301ca5707bfb988b13084e0209ca1c42c Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:03:14 +0000 Subject: [PATCH 36/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index f5de78a..8adb691 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -312,7 +312,7 @@ def startLoop(self, external=False): while self._looping: if not self._speaking and self._queue: self._text_to_say = self._queue.pop(0) - logging.debug(f"Synthesizing text: {self._text_to_say}") + logger.debug(f"Synthesizing text: {self._text_to_say}") self._start_synthesis(self._text_to_say) try: From 0caa7770705fc543bdcf8a782ea30288700ec1eb Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:03:28 +0000 Subject: [PATCH 37/39] logging->logger Co-authored-by: Christian Clauss --- pyttsx3/drivers/espeak.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 8adb691..5b0a4f1 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -341,10 +341,10 @@ def iterate(self): yield def say(self, text): - logging.debug(f"[DEBUG] EspeakDriver.say called with text: {text}") + logger.debug(f"[DEBUG] EspeakDriver.say called with text: {text}") self._queue.append(text) # Add text to the local queue if not self._looping: - logging.debug("[DEBUG] Starting loop from say") + logger.debug("[DEBUG] Starting loop from say") self.startLoop() def runAndWait(self, timeout=0.01): @@ -353,12 +353,12 @@ def runAndWait(self, timeout=0.01): """ # First, check if the loop is already running if self._looping: - logging.debug("[DEBUG] Loop already active; waiting for completion.") + logger.debug("[DEBUG] Loop already active; waiting for completion.") start_time = time.time() while self._looping and (time.time() - start_time < timeout): time.sleep(0.1) if self._looping: - logging.debug("[WARNING] Forcing loop exit due to timeout.") + logger.debug("[WARNING] Forcing loop exit due to timeout.") self.endLoop() self._proxy.setBusy(False) From 8aadd82bc23b9093464d7e5db0ba9fc84d615ce2 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:26:19 +0000 Subject: [PATCH 38/39] last logging to logger --- pyttsx3/drivers/espeak.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 5b0a4f1..350160b 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -28,6 +28,7 @@ class EspeakDriver: _defaultVoice = "" def __init__(self, proxy): + if not EspeakDriver._moduleInitialized: # espeak cannot initialize more than once per process and has # issues when terminating from python (assert error on close) @@ -125,10 +126,10 @@ def setProperty(name: str, value): return try: utf8Value = str(value).encode("utf-8") - logging.debug(f"Attempting to set voice to: {value}") + logger.debug(f"Attempting to set voice to: {value}") result = _espeak.SetVoiceByName(utf8Value) if result == 0: # EE_OK is 0 - logging.debug(f"Successfully set voice to: {value}") + logger.debug(f"Successfully set voice to: {value}") elif result == 1: # EE_BUFFER_FULL raise ValueError( f"SetVoiceByName failed: EE_BUFFER_FULL while setting voice to {value}" @@ -211,6 +212,7 @@ def _onSynth(self, wav, numsamples, events): # Handle utterance completion if self._save_file: # Save audio to file if requested + print('Saving to file') try: with wave.open(self._save_file, "wb") as f: f.setnchannels(1) # Mono From 4014d20df04134614aec08ecd9ac93946af903f7 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 15 Nov 2024 16:35:44 +0000 Subject: [PATCH 39/39] fix pre-commit --- pyttsx3/drivers/espeak.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyttsx3/drivers/espeak.py b/pyttsx3/drivers/espeak.py index 350160b..74f8b70 100644 --- a/pyttsx3/drivers/espeak.py +++ b/pyttsx3/drivers/espeak.py @@ -28,7 +28,6 @@ class EspeakDriver: _defaultVoice = "" def __init__(self, proxy): - if not EspeakDriver._moduleInitialized: # espeak cannot initialize more than once per process and has # issues when terminating from python (assert error on close) @@ -212,7 +211,6 @@ def _onSynth(self, wav, numsamples, events): # Handle utterance completion if self._save_file: # Save audio to file if requested - print('Saving to file') try: with wave.open(self._save_file, "wb") as f: f.setnchannels(1) # Mono