Search code examples
pythonasynchronousaudiopyqt5qthread

QThread speeds up after terminating instead of terminating


I am totally confused by the QThread behavior. My idea is to acquire some audio signal in a qthread, save it in a python queue object and with a QTimer I read the queue and plot it using pyqtgraph. It works, however, only at around 6-7 fps. However, when I use .terminate() to terminate the thread, the thread does actually NOT terminate, but rather speeds up to > 100 fps, exactly what I actually wanted.

My issues:

  • why does the QThread not terminate/is aborted/closed...?
  • what is .terminate() actually doing?
  • what is slowing down the normal thread.start()?

On a side note, I know that I am not using a Signal/Slot for checking if it should still run or not, I just want to understand this strange behavior, and why the thread is not fast from the very beginning! Something is maybe blocking the proper function and is turned off (?!) by the .terminate() function...

My minimal working example (hope you guys have a soundcard/mic somewhere):

from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QPushButton
from PyQt5.QtCore import QThread, QTimer
import sounddevice as sd
import queue
import pyqtgraph as pg
import numpy as np
import time

class Record(QThread):
    def __init__(self):
        super().__init__()
        self.q = queue.Queue()

    def callback(self, indata, frames, time, status):
        self.q.put(indata.copy())

    def run(self):
        with sd.InputStream(samplerate=48000, device=1, channels=2, callback=self.callback, blocksize=4096):
            print('Stream started...')
            while True:
                pass

        print(self.isRunning(), 'Done?') # never called

class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.recording = False
        self.r = None
        self.x = 0
        self.times = list(range(10))

        self.setWindowTitle("Record Audio Tester")

        self.l = QGridLayout()
        self.setLayout(self.l)

        self.pl = pg.PlotWidget(autoRange=False)
        self.curve1 = self.pl.plot(np.zeros(8000))
        self.curve2 = self.pl.plot(np.zeros(8000)-1, pen=pg.mkPen("y"))

        self.l.addWidget(self.pl)

        self.button_record = QPushButton("Start recording")
        self.button_record.clicked.connect(self.record)
        self.l.addWidget(self.button_record)

    def record(self):
        if self.recording and self.r is not None:
            self.button_record.setText("Start recording")
            self.recording = False
            self.r.terminate()

        else:
            self.button_record.setText("Stop recording")
            self.recording = True

            self.r = Record()
            self.r.start()

            self.t = QTimer()
            self.t.timeout.connect(self.plotData)
            self.t.start(0)

    def plotData(self):
        self.times = self.times[1:]
        self.times.append(time.time())

        fps = 1 / (np.diff(np.array(self.times)).mean() + 1e-5)
        self.setWindowTitle("{:d} fps...".format(int(fps)))

        if self.r.q.empty():
            return

        d = self.r.q.get()

        self.curve1.setData(d[:, 0])
        self.curve2.setData(d[:, 1]-3)


if __name__ == '__main__':
    app = QApplication([])

    w = Main()
    w.show()

    app.exec_()

edit 1

The first suggestion @Dennis Jensen was to not subclass QThread, but use rather QObject/QThread/moveToThread. I did this, see code below, and one can see that the issue is gone with either using while and just app.processEvents() or while with time.sleep(0.1), but to make it response you have to use anyway app.processEvents(), so this is sufficient. The pass statement alone eats up alot of CPU processing power, resulting in 7-10 fps, but if you thread.terminate() this thread, everything still runs.

I added additionally a trace, what happens on which thread, and the callback is always on a separate thread, regardless which callback you use (outside any class, in QObject or in the main thread), indicating that the answer from @three_pineapples is correct.

from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QPushButton, QCheckBox
from PyQt5.QtCore import QThread, QTimer, QObject, pyqtSignal, pyqtSlot
import threading
import sounddevice as sd
import queue
import pyqtgraph as pg
import numpy as np
import time

q = queue.Queue()

# It does not matter at all where the callback is,
# it is always on its own thread...
def callback(indata, frames, time, status):
        print("callback", threading.get_ident())
        # print()
        q.put(indata.copy())

class Record(QObject):
    start = pyqtSignal(str)
    stop = pyqtSignal()
    data = pyqtSignal(np.ndarray)

    def __init__(self, do_pass=False, use_terminate=False):
        super().__init__()
        self.q = queue.Queue()
        self.r = None
        self.do_pass = do_pass
        self.stop_while = False
        self.use_terminate = use_terminate
        print("QObject -> __init__", threading.get_ident())

    def callback(self, indata, frames, time, status):
        print("QObject -> callback", threading.get_ident())
        self.q.put(indata.copy())

    @pyqtSlot()
    def stopWhileLoop(self):
        self.stop_while = True

    @pyqtSlot()
    def run(self, m='sth'):
        print('QObject -> run', threading.get_ident())

        # Currently uses a callback outside this QObject
        with sd.InputStream(device=1, channels=2, callback=callback) as stream:
            # Test the while pass function
            if self.do_pass:
                while not self.stop_while:
                    if self.use_terminate: # see the effect of thread.terminate()...
                        pass # 7-10 fps
                    else:
                        app.processEvents() # makes it real time, and responsive

                print("Exited while..")
                stream.stop()

            else:
                while not self.stop_while:
                    app.processEvents() # makes it responsive to slots
                    time.sleep(.01) # makes it real time

                stream.stop()

        print('QObject -> run ended. Finally.')

class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.recording = False
        self.r = None
        self.x = 0
        self.times = list(range(10))
        self.q = queue.Queue()

        self.setWindowTitle("Record Audio Tester")

        self.l = QGridLayout()
        self.setLayout(self.l)

        self.pl = pg.PlotWidget(autoRange=False)
        self.curve1 = self.pl.plot(np.zeros(8000))
        self.curve2 = self.pl.plot(np.zeros(8000)-1, pen=pg.mkPen("y"))

        self.l.addWidget(self.pl)

        self.button_record = QPushButton("Start recording")
        self.button_record.clicked.connect(self.record)
        self.l.addWidget(self.button_record)

        self.pass_or_sleep = QCheckBox("While True: pass")
        self.l.addWidget(self.pass_or_sleep)

        self.use_terminate = QCheckBox("Use QThread terminate")
        self.l.addWidget(self.use_terminate)

        print("Main thread", threading.get_ident())

    def streamData(self):
        self.r = sd.InputStream(device=1, channels=2, callback=self.callback)

    def record(self):
        if self.recording and self.r is not None:
            self.button_record.setText("Start recording")
            self.recording = False
            self.r.stop.emit()

            # And this is where the magic happens:
            if self.use_terminate.isChecked():
                self.thr.terminate()

        else:
            self.button_record.setText("Stop recording")
            self.recording = True

            self.t = QTimer()
            self.t.timeout.connect(self.plotData)
            self.t.start(0)

            self.thr = QThread()
            self.thr.start()

            self.r = Record(self.pass_or_sleep.isChecked(), self.use_terminate.isChecked())
            self.r.moveToThread(self.thr)
            self.r.stop.connect(self.r.stopWhileLoop)
            self.r.start.connect(self.r.run)
            self.r.start.emit('go!')

    def addData(self, data):
        # print('got data...')
        self.q.put(data)

    def callback(self, indata, frames, time, status):
        self.q.put(indata.copy())
        print("Main thread -> callback", threading.get_ident())


    def plotData(self):
        self.times = self.times[1:]
        self.times.append(time.time())

        fps = 1 / (np.diff(np.array(self.times)).mean() + 1e-5)
        self.setWindowTitle("{:d} fps...".format(int(fps)))

        if q.empty():
            return

        d = q.get()
        # print("got data ! ...")

        self.curve1.setData(d[:, 0])
        self.curve2.setData(d[:, 1]-1)


if __name__ == '__main__':
    app = QApplication([])

    w = Main()
    w.show()

    app.exec_()

edit 2

Here the code that uses no QThread environment, and this works as expected!

from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QPushButton, QCheckBox
from PyQt5.QtCore import QTimer
import threading
import sounddevice as sd
import queue
import pyqtgraph as pg
import numpy as np
import time


class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.recording = False
        self.r = None
        self.x = 0
        self.times = list(range(10))
        self.q = queue.Queue()

        self.setWindowTitle("Record Audio Tester")

        self.l = QGridLayout()
        self.setLayout(self.l)

        self.pl = pg.PlotWidget(autoRange=False)
        self.curve1 = self.pl.plot(np.zeros(8000))
        self.curve2 = self.pl.plot(np.zeros(8000)-1, pen=pg.mkPen("y"))

        self.l.addWidget(self.pl)

        self.button_record = QPushButton("Start recording")
        self.button_record.clicked.connect(self.record)
        self.l.addWidget(self.button_record)

        print("Main thread", threading.get_ident())

    def streamData(self):
        self.r = sd.InputStream(device=1, channels=2, callback=self.callback)
        self.r.start()

    def record(self):
        if self.recording and self.r is not None:
            self.button_record.setText("Start recording")
            self.recording = False
            self.r.stop()

        else:
            self.button_record.setText("Stop recording")
            self.recording = True

            self.t = QTimer()
            self.t.timeout.connect(self.plotData)
            self.t.start(0)

            self.streamData()

    def callback(self, indata, frames, time, status):
        self.q.put(indata.copy())
        print("Main thread -> callback", threading.get_ident())


    def plotData(self):
        self.times = self.times[1:]
        self.times.append(time.time())

        fps = 1 / (np.diff(np.array(self.times)).mean() + 1e-5)
        self.setWindowTitle("{:d} fps...".format(int(fps)))

        if self.q.empty():
            return

        d = self.q.get()
        # print("got data ! ...")

        self.curve1.setData(d[:, 0])
        self.curve2.setData(d[:, 1]-1)


if __name__ == '__main__':
    app = QApplication([])

    w = Main()
    w.show()

    app.exec_()

Solution

  • The problem is due to the while True: pass line in your thread. To understand why, you need to understand how PortAudio (the library wrapped by sounddevice) works.

    Anything passed a callback like you are doing with InputStream is likely calling the provided method from a separate thread (not the main thread or your QThread). Now from what I can tell, whether the callback is called from a separate thread or some sort of interrupt is platform dependent, but either way it is operating somewhat independently of your QThread even though the method exists inside that class.

    The while True: pass is going to consume close to 100% of your CPU, limiting what any other thread can do. That is until you terminate it! Which frees up resources for whatever is actually calling the callback to work faster. While you might expect the audio capture to be killed along with your thread, chances are it hasn't been garbage collected yet (and garbage collection gets complicated when dealing with C/C++ wrapped libraries, nevermind when you have two of them! [PortAudio and Qt] - And there is a good chance that garbage collection in Python might not actually free the resources in your case anyway!)

    So this explains why things get faster when you terminate the thread.

    The solution is to change your loop to while True: time.sleep(.1) which will ensure it doesn't consume resources unnecessarily! You could also look into whether you actually need that thread at all (depending on how PortAudio works on your platform). If you move to the signal/slot architecture and do away with the with statement (managing the open/close of the resource in separate slots) that would also work as you wouldn't need the problematic loop at all.