Search code examples
pythonpython-multithreadingnonetypeconcurrent.futurespyttsx

'NoneType' object has no attribute 'earlierDate_'


I am just starting to learn Python and in order to do so, I was working on implementing a simple chat-bot. That worked fine until I wanted to implement some text-to-speech functionality which speaks the lines while they are appearing on the screen. To achieve that, I had to dive into multi-threading and that's where I'm stuck:

import concurrent.futures
import pyttsx3
from time import sleep
import sys

# Settings
engine = pyttsx3.init()
voices = engine.getProperty('voices')
engine.setProperty('voice', voices[0].id)
typing_delay=0.035

def textToSpeech(text):
    engine.say(text)
    engine.runAndWait()  
 
def typing(sentence):
    for char in sentence:
        sleep(typing_delay)
        sys.stdout.write(char)
        sys.stdout.flush()

# def parallel(string):
#     tasks = [lambda: textToSpeech(string), lambda: typing("\n> "+string+"\n\n")]
#     with ThreadPoolExecutor(max_workers=2) as executor:
#         futures = [executor.submit(task) for task in tasks]      
#         for future in futures:
#             try:
#                 future.result()
#             except Exception as e:
#                 print(e)
        
def parallel(text):
    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        future_tasks = {executor.submit(textToSpeech, text), executor.submit(typing, "\n> "+text+"\n\n")}
        for future in concurrent.futures.as_completed(future_tasks):
            try:
                data = future.result()
            except Exception as e:
                print(e)

# Test Sentence
parallel("Greetings Professor Falken")

The two functions on top are supposed to run in parallel. I've tried two different implementations for my parallel() function (one is commented out), both of which yield the same result however. For the first line of text that the chat-bot puts out, I do actually get both text & speech, but then I get the error: 'NoneType' object has no attribute 'earlierDate_' After that, I only get text, no more speech and the error: run loop already started

I assume that somewhere in concurrent.futures is the attribute 'earlierDate_' and that I'm not handling it correctly, so that the text-to-speech thread never stops. But I have no idea how to fix it.

I hope someone here has an idea that might help. I've cut down my code to something that is as small as possible but still can be run and tested.

Addendum: I had issues with importing pyttsx3 on Python 3.8, so I downgraded to Python 3.7 where it seems to work.


UPDATE: So it occurred to me, that while I was focusing on the multithreading, the issue might have been with my text-to-speech implementation all along.

The obvious bit was me initialising my speech engine globally. So I moved my settings into the textToSpeech function:

def textToSpeech(text):
    engine = pyttsx3.init()
    voices = engine.getProperty('voices')
    engine.setProperty('voice', voices[0].id)
    engine.say(text)
    engine.runAndWait()

The run loop already started Error now doesn't appear right away and I get text & speech throughout the first couple of chatbot interactions. I still get the 'NoneType' object has no attribute 'earlierDate_' error, now after every chat-bot output though and eventually the run loop already started Error kicks in again and I lose the sound. Still, one step closer I guess.


UPDATE2:

After another day of digging, I think I'm another step closer. This seems to be a Mac-specific issue related to multi-threading. I've found multiple issues across different areas where people ran into this problem.

I've located the issue within PyObjCTools/AppHelper.py

There we have the following function:

def runConsoleEventLoop(
    argv=None, installInterrupt=False, mode=NSDefaultRunLoopMode, maxTimeout=3.0
):
    if argv is None:
        argv = sys.argv
    if installInterrupt:
        installMachInterrupt()
    runLoop = NSRunLoop.currentRunLoop()
    stopper = PyObjCAppHelperRunLoopStopper.alloc().init()
    PyObjCAppHelperRunLoopStopper.addRunLoopStopper_toRunLoop_(stopper, runLoop)
    try:

        while stopper.shouldRun():
            nextfire = runLoop.limitDateForMode_(mode)
            if not stopper.shouldRun():
                break

            soon = NSDate.dateWithTimeIntervalSinceNow_(maxTimeout)
            nextfire = nextfire.earlierDate_(soon)
            if not runLoop.runMode_beforeDate_(mode, nextfire):
                stopper.stop()

    finally:
        PyObjCAppHelperRunLoopStopper.removeRunLoopStopperFromRunLoop_(runLoop)

This line close to the button is the culprit: nextfire = nextfire.earlierDate_(soon) The object nextfire seems to be a date. In Objective-C, NSDate objects do indeed have an earlierDate() method, so it should work. But something's wrong with the initialization. When I print(nextfire), I get None. No surprise then that a NoneType object doesn't have the attribute 'earlierDate_'.


Solution

  • So, I have kinda solved both of my issues, however, I solved them both in an unsatisfying way. It's a bit hacky. But it's the best I could do, short of dissecting pyttsx3.

    1) First, for my issue with the run loop already started error: I have moved my engine initialisation back onto a global level outside the textToSpeech function (like in my initial code snippet). Then, every time before I call my textToSpeech function, I put in the following code:

    try:
        engine.endLoop()
    except Exception as e:
        pass
    

    This way, when there is a loop already running, it gets stopped before the new call, preventing the error from happening. If there's no loop running, nothing happens.

    2) My main issue with the 'NoneType' object has no attribute 'earlierDate_' error runs a bit deeper. I have looked at the various sources, but I don't completely follow as to what is going on there. As I wrote in my second update, the error originates in PyObjCTools/AppHelper.py. nextfire is initialized with the limitDateForMode method from NSRunLoop which according to the Apple documentation returns nil if there are no input sources for this mode.

    The next step was to look at pyttsx3/nsss.py where this method from PyObjCTools/AppHelper.py gets instantiated. But I didn't really grasp how that instantiation works and how to fix the fact that NSRunLoop apparently gets instantiated without an input source or if that should be fixed at all. So I went for a dirty hack and changed PyObjCTools/AppHelper.py by switching nextfire and soon in the earlierDate_ call:

        try:
    
            while stopper.shouldRun():
                nextfire = runLoop.limitDateForMode_(mode)
                if not stopper.shouldRun():
                    break
    
                soon = NSDate.dateWithTimeIntervalSinceNow_(maxTimeout)
                nextfire = soon.earlierDate_(nextfire)
                if not runLoop.runMode_beforeDate_(mode, nextfire):
                    stopper.stop()
    
        finally:
            PyObjCAppHelperRunLoopStopper.removeRunLoopStopperFromRunLoop_(runLoop)
    

    The soon variable is always correctly instantiated as an NSDate object, so the method earlierDate is available. And apparently it also works with nil as an argument.

    Of course it would be nicer if there was a proper fix within one of the two involved libraries. My guess is that pyttsx3/nsss.py needs some work, but I don't know enough about NSRunLoops to come to an informed opinion.