1

I'm using wxpython to build the front-end GUI for a command-line tool that analyzes and processes audio files. The files are loaded into the GUI; then threads are launched that perform the analysis and adjustment steps; and finally the results of those processes are displayed in the main window.

I have endeavored to write thread safe code; however, some threads still arbitrarily fail to complete (it should be noted that when I manually launch them a second time, they typically run to completion). Below I have included an abridged version of my program, which contains classes for the AnalysisThread, the AdjustThread, and the MainWindow. Buttons in the main window are bound to the functions "OnAnalyze" and "OnAdjust," which create instances of the appropriate thread classes. The threads themselves communicate with the GUI via wx.CallAfter and Publisher. To my understanding, this should allow data to be passed safely back and forth between the main process and the threads. If someone could kindly point out where I went wrong with the code below, I'd be very grateful.

If I cannot fix the thread safety issue, my back-up plan is to somehow detect the death of a thread and try to "resuscitate" it under the hood, without the user knowing there was a glitch. Does this seem reasonable? If so, advice on how to accomplish this would be most welcome.

Thanks very much.

#!/usr/bin/python

import wx
import time
from threading import Thread
import os, sys, re, subprocess, shutil
from wx.lib.pubsub import setuparg1
from wx.lib.pubsub import pub as Publisher


#Start a thread that analyzes audio files.
class AnalysisThread(Thread):
    def __init__(self,args):
        Thread.__init__(self)
        self.file = args[0]
        self.index = args[1]
        self.setDaemon(True)
        self.start()

    def run(self):
       proc = subprocess.Popen(['ffmpeg', '-nostats', '-i', self.file, '-filter_complex', 'ebur128=peak=true+sample', '-f', 'null', '-'], bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
       flag = 0
       summary = ""
       while proc.poll() is None:
          line = proc.stdout.readline()
          if line:
             endProcess = re.search(r'Summary', line)
             if endProcess is not None:
                flag = 1
             if flag:
                summary += line

       wx.CallAfter(Publisher.sendMessage, "update", (self.file, summary, self.index))

#Start a thread that adjusts audio files so that they conform to EBU loudness standards.
class AdjustThread(Thread):
    def __init__(self,args):
        Thread.__init__(self)
        self.file = args[0]
        self.index = args[1]
        self.IL = args[2]
        self.TP = args[3]
        self.SP = args[4]
        self.setDaemon(True)
        self.start()

    def run(self):

       proc = subprocess.Popen(['ffmpeg', '-nostats', '-i', adjusted_file, '-filter_complex', 'ebur128=peak=true+sample', '-f', 'null', '-'], bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
       flag = 0
       summary = ""
       while proc.poll() is None:
          line = proc.stdout.readline()
          if line:
             endProcess = re.search(r'Summary', line)
             if endProcess is not None:
                flag = 1
             if flag:
                summary += line

       wx.CallAfter(Publisher.sendMessage, "update", (self.file, summary, self.index))


class MainWindow(wx.Frame):

    fileList = collections.OrderedDict()

    def __init__(self, parent, id, title):
        wx.Frame.__init__(self, parent, id, title, size=(900, 400))

        Publisher.subscribe(self.UpdateDisplay, "update")

        #Add "analyze" and "Adjust" buttons to the main frame.
        panel = wx.Panel(self, -1)
        vbox = wx.BoxSizer(wx.VERTICAL)
        self.ana = wx.Button(panel, -1, 'Analyze', size=(100, -1))
        self.adj = wx.Button(panel, -1, 'Adjust', size=(100, -1))
        self.Bind(wx.EVT_BUTTON, self.OnAnalyze, id=self.ana.GetId())
        self.Bind(wx.EVT_BUTTON, self.OnAdjust, id=self.adj.GetId())
        vbox.Add(self.ana, 0, wx.ALL, 10)
        vbox.Add(self.adj, 0, wx.ALL, 10)
        vbox.Add(self.list, 1, wx.EXPAND | wx.TOP, 3)
        vbox.Add((-1, 10))
        panel.SetSizer(hbox)

        self.Centre()
        self.Show(True)

    #This function gets called when "Analyze" is pressed.
    def OnAnalyze(self, event):

        for (file,index) in toAnalyze:

           #Add a progess bar
           item = self.list.GetItem(index,2)
           gauge = item.GetWindow()
           gauge.Pulse()

           #Launch the analysis thread
           AnalysisThread(args=(file,index,))

    #This function gets called when "Adjust" is pressed.
    def OnAdjust(self, event):

        for (file,index) in toAdjust:
           gauge = wx.Gauge(self.list,-1,range=50,size=(width,15),style=wx.GA_HORIZONTAL | wx.GA_SMOOTH)
           gauge.Pulse() #shouldn't start this right away...
           item.SetWindow(gauge, wx.ALIGN_CENTRE)
           self.list.SetItem(item) 

           #Launch the adjust thread
           AdjustThread(args=(file,index,intloud,truepeak,samplepeak))

    #This function is invoked by the Publisher.
    def UpdateDisplay(self, msg):
       t = msg.data 
       file = t[0]
       summary = t[1]
       i = t[2]
       self.ProcessSummary(file, summary, i)  
       item = self.list.GetItem(i,2)
       gauge = item.GetWindow()
       gauge.SetValue(50)
       self.fileList[file][1] = True

    #Display information from the threads in the main frame.
    def ProcessSummary(self, file, summary, i):
      loudnessRange = re.search(r'LRA:\s(.+?) LU', summary)
      if loudnessRange is not None:
         LRA = loudnessRange.group(1)
      else:
         LRA = "n/a"
      self.list.SetStringItem(i,7,LRA)         
      self.fileList[file][6] = LRA

      intloud = re.search(r'I:\s(.+?) LUFS', summary)
      if intloud is not None:
         IL = intloud.group(1)
      else:
         IL = "n/a"
      self.list.SetStringItem(i,4,IL)
      self.fileList[file][3] = IL

      truePeak = re.search(r'True peak:\s+Peak:\s(.+?) dBFS', summary)
      if truePeak is not None:
          TP = truePeak.group(1)
      else:
          TP = "n/a"
      self.list.SetStringItem(i,5,TP)
      self.fileList[file][4] = TP

      samplePeak = re.search(r'Sample peak:\s+Peak:\s(.+?) dBFS', summary)
      if samplePeak is not None:
          SP = samplePeak.group(1)
      else:
          SP = "n/a"
      self.list.SetStringItem(i,6,SP)
      self.fileList[file][5] = SP


app = wx.App()
MainWindow(None, -1, 'Leveler')
app.MainLoop()
arafasse
  • 47
  • 10
  • Have you looked at this answer already? http://stackoverflow.com/a/18495032/566035 I personally did something very similar using http://wxpython.org/docs/api/wx.lib.delayedresult-module.html too. delayedresult was nice too. – otterb Apr 23 '15 at 15:34
  • Hi otterb, I did see that stack overflow post and actually tried to integrate some of those solutions into my own code... but thank you very much for the link to the delayedresult module! I'll definitely check it out and let you know how it goes. Just for my own peace of mind: am I correct in assuming that the intermittent and arbitrary failure of threads is _not_ an unavoidable attribute of wxPython GUIs? I'm building a product that will be shipped to quite a few users, so robustness is of the utmost importance. Thanks! – arafasse Apr 23 '15 at 15:51
  • at least with my own experience, delayedresult worked very well and was easy to implement (for me). I believe both python and wxpython (I used 2.8 not 3) are very matured so it should be stable. wxpython 3 is relatively new (still beta i guess) and I do not have much experience. – otterb Apr 23 '15 at 15:57
  • Hi otterb: would you be able to provide an example usage of the delayedresult module? I've gotten as far as something like "startWorker(self.OnAnalyze, self.AnalysisWorker, wargs=(file,))", where self.OnAnalyze is the current function and self.AnalysisWorker is the function that calls the thread, given the 'file' argument. I've reduced self.AnalysisWorker to a single print statement, and it appears to be looping infinitely. Have I specified the wrong consumer? Where does the thread actually get called, and in what form is the data returned to the GUI? Thanks! – arafasse Apr 24 '15 at 18:01
  • Here's a more specific (and perhaps more useful) question. Having played with my code for a while, I feel like I've got a handle on the delayedresult module except for one thing: how do you pass additional arguments (besides a delayedresult object) to the consumer function? I've tried using cargs=(arg1, arg2,) in the call to startWorker, but I keep getting errors. – arafasse Apr 24 '15 at 20:40
  • i think i used `wargs` like `delayedresult.startWorker(self._resultConsumer, self.actualJob, wargs=(self.jobID, self.abortEvent, param1), jobID=self.jobID )` – otterb Apr 24 '15 at 21:37

1 Answers1

1

Here's a sample code for delayedresult taken from wxPython demo for 2.8 series. I modified this code to suit my need.

By the way, wxPython demo is a valuable source to learn wx. I highly recommend. With the demo, wxPython is fun to learn!

It seems version 3 is now officially released and therefore stable. But if you are using an older version, you can find the demo here at: http://sourceforge.net/projects/wxpython/files/wxPython/

import wx
import wx.lib.delayedresult as delayedresult


class FrameSimpleDelayedBase(wx.Frame):
    def __init__(self, *args, **kwds):
        wx.Frame.__init__(self, *args, **kwds)
        pnl = wx.Panel(self)
        self.checkboxUseDelayed = wx.CheckBox(pnl, -1, "Using delayedresult")
        self.buttonGet = wx.Button(pnl, -1, "Get")
        self.buttonAbort = wx.Button(pnl, -1, "Abort")
        self.slider = wx.Slider(pnl, -1, 0, 0, 10, size=(100,-1),
                                style=wx.SL_HORIZONTAL|wx.SL_AUTOTICKS)
        self.textCtrlResult = wx.TextCtrl(pnl, -1, "", style=wx.TE_READONLY)

        self.checkboxUseDelayed.SetValue(1)
        self.checkboxUseDelayed.Enable(False)
        self.buttonAbort.Enable(False)

        vsizer = wx.BoxSizer(wx.VERTICAL)
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        vsizer.Add(self.checkboxUseDelayed, 0, wx.ALL, 10)
        hsizer.Add(self.buttonGet, 0, wx.ALL, 5)
        hsizer.Add(self.buttonAbort, 0, wx.ALL, 5)
        hsizer.Add(self.slider, 0, wx.ALL, 5)
        hsizer.Add(self.textCtrlResult, 0, wx.ALL, 5)
        vsizer.Add(hsizer, 0, wx.ALL, 5)
        pnl.SetSizer(vsizer)
        vsizer.SetSizeHints(self)

        self.Bind(wx.EVT_BUTTON, self.handleGet, self.buttonGet)
        self.Bind(wx.EVT_BUTTON, self.handleAbort, self.buttonAbort)




class FrameSimpleDelayed(FrameSimpleDelayedBase):
    """This demos simplistic use of delayedresult module."""

    def __init__(self, *args, **kwargs):
        FrameSimpleDelayedBase.__init__(self, *args, **kwargs)
        self.jobID = 0
        self.abortEvent = delayedresult.AbortEvent()
        self.Bind(wx.EVT_CLOSE, self.handleClose)

    def setLog(self, log):
        self.log = log

    def handleClose(self, event):
        """Only needed because in demo, closing the window does not kill the 
        app, so worker thread continues and sends result to dead frame; normally
        your app would exit so this would not happen."""
        if self.buttonAbort.IsEnabled():
            self.log( "Exiting: Aborting job %s" % self.jobID )
            self.abortEvent.set()
        self.Destroy()

    def handleGet(self, event): 
        """Compute result in separate thread, doesn't affect GUI response."""
        self.buttonGet.Enable(False)
        self.buttonAbort.Enable(True)
        self.abortEvent.clear()
        self.jobID += 1

        self.log( "Starting job %s in producer thread: GUI remains responsive"
                  % self.jobID )
        delayedresult.startWorker(self._resultConsumer, self._resultProducer, 
                                  wargs=(self.jobID,self.abortEvent), jobID=self.jobID)


    def _resultProducer(self, jobID, abortEvent):
        """Pretend to be a complex worker function or something that takes 
        long time to run due to network access etc. GUI will freeze if this 
        method is not called in separate thread."""
        import time
        count = 0
        while not abortEvent() and count < 50:
            time.sleep(0.1)
            count += 1
        return jobID


    def handleAbort(self, event): 
        """Abort the result computation."""
        self.log( "Aborting result for job %s" % self.jobID )
        self.buttonGet.Enable(True)
        self.buttonAbort.Enable(False)
        self.abortEvent.set()


    def _resultConsumer(self, delayedResult):
        jobID = delayedResult.getJobID()
        assert jobID == self.jobID
        try:
            result = delayedResult.get()
        except Exception, exc:
            self.log( "Result for job %s raised exception: %s" % (jobID, exc) )
            return

        # output result
        self.log( "Got result for job %s: %s" % (jobID, result) )
        self.textCtrlResult.SetValue(str(result))

        # get ready for next job:
        self.buttonGet.Enable(True)
        self.buttonAbort.Enable(False)


class FrameSimpleDirect(FrameSimpleDelayedBase):
    """This does not use delayedresult so the GUI will freeze while
    the GET is taking place."""

    def __init__(self, *args, **kwargs):
        self.jobID = 1
        FrameSimpleDelayedBase.__init__(self, *args, **kwargs)
        self.checkboxUseDelayed.SetValue(False)

    def setLog(self, log):
        self.log = log

    def handleGet(self, event): 
        """Use delayedresult, this will compute result in separate
        thread, and will affect GUI response because a thread is not
        used."""
        self.buttonGet.Enable(False)
        self.buttonAbort.Enable(True)

        self.log( "Doing job %s without delayedresult (same as GUI thread): GUI hangs (for a while)" % self.jobID )
        result = self._resultProducer(self.jobID)
        self._resultConsumer( result )

    def _resultProducer(self, jobID):
        """Pretend to be a complex worker function or something that takes 
        long time to run due to network access etc. GUI will freeze if this 
        method is not called in separate thread."""
        import time
        time.sleep(5)
        return jobID

    def handleAbort(self, event):
        """can never be called"""
        pass

    def _resultConsumer(self, result):
        # output result
        self.log( "Got result for job %s: %s" % (self.jobID, result) )
        self.textCtrlResult.SetValue(str(result))

        # get ready for next job:
        self.buttonGet.Enable(True)
        self.buttonAbort.Enable(False)
        self.jobID += 1


#---------------------------------------------------------------------------
#---------------------------------------------------------------------------

class TestPanel(wx.Panel):
    def __init__(self, parent, log):
        self.log = log
        wx.Panel.__init__(self, parent, -1)

        vsizer = wx.BoxSizer(wx.VERTICAL)
        b = wx.Button(self, -1, "Long-running function in separate thread")
        vsizer.Add(b, 0, wx.ALL, 5)
        self.Bind(wx.EVT_BUTTON, self.OnButton1, b)

        b = wx.Button(self, -1, "Long-running function in GUI thread")
        vsizer.Add(b, 0, wx.ALL, 5)
        self.Bind(wx.EVT_BUTTON, self.OnButton2, b)

        bdr = wx.BoxSizer()
        bdr.Add(vsizer, 0, wx.ALL, 50)
        self.SetSizer(bdr)
        self.Layout()

    def OnButton1(self, evt):
        frame = FrameSimpleDelayed(self, title="Long-running function in separate thread")
        frame.setLog(self.log.WriteText)
        frame.Show()

    def OnButton2(self, evt):
        frame = FrameSimpleDirect(self, title="Long-running function in GUI thread")
        frame.setLog(self.log.WriteText)
        frame.Show()
otterb
  • 2,660
  • 2
  • 29
  • 48