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:
.terminate()
actually doing?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_()
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.