Lachlan
Lachlan

Reputation: 370

Python Tkinter audio playback GUI play/pause functionality with pyaudio - can't resume from where paused

I am building a small audio GUI for processing ecological recordings. I want to be able to play a file, and pause it, and play it again from where I paused it. I can get it to play, and pause(stop), but when I hit play again it restarts the audio, not picks up from where it left off. Pyaudio has the callback function which is what I am trying to implement (see here for working example). That example is pretty much what I want, except where this example has the 'keyboard.Listener' line in the while statement controlling the play/pause, I need to implement the play/pause button functionality from tkinter. I have also threaded the playback so that the GUI does not freeze the buttons, which has added some more complexity for me (I am an ecologist self taught in python, not a computer scientist!). I have played around with threading.Event() for this as a way to control the stream thread, but I think that will just add additional complexity and leave me at the same problem of restarting from pause location.

Eventually I'd also like to pull out the frame number/time of file when paused, and also plot a progress bar on the tkinter canvas/matplot Figure - part of me says the pyaudio .get_time() embedded within the callback may be able to help with this (i think it returns system time).

Below is a minimum example I could make to get a gui working with where Im at.

import tkinter as tk
from tkinter import ttk
import wave
import pyaudio
import threading
import time
import numpy as np
import datetime
from matplotlib.figure import Figure 
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

# gui class
class basic_player():    
    def __init__(self, root):
        # BUILD ROOT 
        self.root = root
        root.title('Playback')

        self.audio_file = 'C:/Data/Acoustics/Test/5D8CA5E8.WAV'

        self.frame_plot()
        self.frame_buttons()

        # class globals
        self.stream_paused = False

    def frame_plot(self):
        '''Frame for file plot'''
        self.frame_plot = ttk.Frame(self.root, height = 100, width = 500)
        self.frame_plot.grid(column = 0, row = 0, sticky = 'nsew', columnspan = 2)
        self.frame_plot.grid_propagate(False)
        # plot file
        self.func_plot_fileplot()

    def func_plot_fileplot(self):
        '''Plot the main audiofile'''
        # create figure to contain plot
        # get frame size parameters (update frame parameters first)
        self.frame_plot.update()
        dpi = self.root.winfo_fpixels('1i')
        plotwidth = self.frame_plot.winfo_width() / dpi
        plotheight = self.frame_plot.winfo_height() / dpi

        # create plot
        plot_figure_fileplot_main = Figure(figsize = (plotwidth, plotheight),
                                           dpi = dpi, frameon = False, tight_layout = True)

        # get data
        with wave.open(self.audio_file, mode = 'rb') as wf:
            infile_audio_bytes = wf.readframes(wf.getnframes())
            data = np.frombuffer(infile_audio_bytes, dtype = np.int16)
            # plot x labels
            lst_x_ticks = list(range(0, wf.getnframes(), int(wf.getnframes() / 8)))  + [wf.getnframes()]
            lst_x_label = [str(datetime.timedelta(seconds = int(sample / wf.getframerate()))) for sample in lst_x_ticks]

        # add subplot
        plot_figure_fileplot = plot_figure_fileplot_main.add_subplot(111)
        plot_figure_fileplot.plot(data, linewidth = 0.25)
        # adjust subplot visuals
        plot_figure_fileplot.set_xmargin(0)
        plot_figure_fileplot.yaxis.set_visible(False)
        plot_figure_fileplot.spines['top'].set_visible(False)
        plot_figure_fileplot.spines['right'].set_visible(False)
        plot_figure_fileplot.spines['left'].set_visible(False)

        # labels for plot x axis       
        plot_figure_fileplot.set_xticks(lst_x_ticks) # set x labels to existing to make sure they find the right spot
        plot_figure_fileplot.set_xticklabels(lst_x_label, size = 8)

        #create tkinter canvas
        self.canvas_plot_figure_main = FigureCanvasTkAgg(plot_figure_fileplot_main, master = self.frame_plot)  
        self.canvas_plot_figure_main.draw()
        
        #place canvas on tkinter window
        self.canvas_plot_figure_main.get_tk_widget().grid(sticky = 'nsew')

    def frame_buttons(self):
        '''The main frame for the initial window'''
        frame_buttons = ttk.Frame(self.root, width = 100)
        frame_buttons.grid(column = 0, row = 1, sticky = 'nsew')

        btn_play = tk.Button(frame_buttons,
                             text = 'PLAY',
                             command = self.buttons_command_play,
                             state = 'normal',
                             width = 10)
        btn_play.grid(column = 0, row = 0, sticky = 'nsew', padx = 10, pady = 10)

        btn_pause = tk.Button(frame_buttons,
                             text = 'PAUSE',
                             command = self.buttons_command_playback_pause,
                             state = 'normal',
                             width = 10)
        btn_pause.grid(column = 1, row = 0, sticky = 'nsew', padx = 10, pady = 10)

    def buttons_command_play(self):
        ''' send play audio function to thread '''
        self.stream_paused = False
        self.stream_thread = threading.Thread(target = self.play_audio)
        self.stream_thread.start()

    def play_audio(self):
        '''Play audio'''
        if self.stream_paused:  # this doesnt work.
            self.stream.start_stream()

        else:
            # open file
            wf = wave.open(self.audio_file, mode = 'rb')

            # instantiate pyaudio
            self.pyaudio_init = pyaudio.PyAudio()

            # define callback
            def callback(in_data, frame_count, time_info, status):
                data = wf.readframes(frame_count)
                return (data, pyaudio.paContinue)

            # open stream using callback
            self.stream = self.pyaudio_init.open(format=self.pyaudio_init.get_format_from_width(wf.getsampwidth()),
                            input = False,
                            channels=wf.getnchannels(),
                            rate=wf.getframerate(),
                            output=True,
                            stream_callback=callback)

            self.stream.start_stream()

            # start the stream
            while self.stream.is_active() and not self.stream_paused:
                # this is where the control event needs to work i believe
                time.sleep(0.1)

            # stop stream 
            self.stream.stop_stream()      
            self.stream.close()
            wf.close() 

    def buttons_command_playback_pause(self):
        ''' Pause the audio '''
        if not self.stream_paused:
            self.stream_paused = True
        else:
            pass

## SETUP AND RUN
root = tk.Tk()
basic_player(root)
root.mainloop()

Upvotes: 1

Views: 1832

Answers (2)

TheLizzard
TheLizzard

Reputation: 7680

This answer is the same as @Art's answer but I removed the self.after_id variable to simplify the logic a bit:

import tkinter as tk
import threading
import pyaudio
import wave
import time


class SamplePlayer:
    def __init__(self, master):
        frame = tk.Frame(master=master)
        frame.pack(expand=True, fill="both")

        self.current_lbl = tk.Label(master=frame, text="0/0")
        self.current_lbl.pack()

        self.pause_btn = tk.Button(master=frame, text="Pause", command=self.pause)
        self.pause_btn.pack()

        self.play_btn = tk.Button(master=frame, text="Play", command=self.play)
        self.play_btn.pack()

        # If you aren't going to use `\`s there is no need for the
        # "r" before the start of the string
        self.file = r"sample_wavfile.wav"

        self.paused = True
        self.playing = False

        self.audio_length = 0
        self.current_sec = 0

    def start_playing(self):
        """ # I don't have `pyaudio` so I used this to test my answer:
        self.audio_length = 200
        while self.playing:
            if not self.paused:
                self.current_sec += 1
                time.sleep(1)
        return None
        # """

        p = pyaudio.PyAudio()
        chunk = 1024
        with wave.open(self.file, "rb") as wf:
            self.audio_length = wf.getnframes() / float(wf.getframerate())

            stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
                            channels=wf.getnchannels(),
                            rate=wf.getframerate(),
                            output=True)

            data = wf.readframes(chunk)

            chunk_total = 0
            while data != b"" and self.playing:
                if self.paused:
                    time.sleep(0.1)
                else:
                    chunk_total += chunk
                    stream.write(data)
                    data = wf.readframes(chunk)
                    self.current_sec = chunk_total/wf.getframerate()

        self.playing = False
        stream.close()   
        p.terminate()

    def pause(self):
        self.paused = True
    
    def play(self):
        if not self.playing:
            self.playing = True
            threading.Thread(target=self.start_playing, daemon=True).start()

        if self.paused:
            self.paused = False
            self.update_lbl()

    def stop(self):
        self.playing = False

    def update_lbl(self):
        if self.playing and (not self.paused):
            self.current_lbl.config(text=f"{self.current_sec}/{self.audio_length}")
            # There is no need to update the label more than 10 times a second.
            # It changes once per second anyways.
            self.current_lbl.after(100, self.update_lbl)


def handle_close():
    player.stop()
    root.destroy()

## SETUP AND RUN
root = tk.Tk()
player = SamplePlayer(root)

root.protocol("WM_DELETE_WINDOW", handle_close)
root.mainloop()

There is no need for a self.after_id variable if you can just add if self.playing and (not self.paused) to the code that calls .after. In this case it's the update_lbl method.

Upvotes: 2

Art
Art

Reputation: 3089

stream_callback is unnecessary here. You could instead create a new thread and run the stream.write() in a loop.

To pause the audio set a flag, and add a condition in the loop. write the stream only if the pause condition is False

Here is an example.

import tkinter as tk
import wave
import pyaudio
import threading


class SamplePlayer:

    def __init__(self, master):

        frame = tk.Frame(master=master)
        frame.pack(expand=True, fill="both")


        self.current_lbl = tk.Label(master=frame, text="0/0")
        self.current_lbl.pack()

        self.pause_btn = tk.Button(master=frame, text="Pause", command=self.pause)
        self.pause_btn.pack()

        self.play_btn = tk.Button(master=frame, text="Play", command=self.play)
        self.play_btn.pack()

        self.file = r"sample_wavfile.wav"

        self.paused = True
        self.playing = False

        self.audio_length = 0
        self.current_sec = 0
        self.after_id = None

    def start_playing(self):  
        
        p = pyaudio.PyAudio()
        chunk = 1024
        with wave.open(self.file, "rb") as wf:
            
            self.audio_length = wf.getnframes() / float(wf.getframerate())

            stream = p.open(format =
                    p.get_format_from_width(wf.getsampwidth()),
                    channels = wf.getnchannels(),
                    rate = wf.getframerate(),
                    output = True)

            data = wf.readframes(chunk)

            chunk_total = 0
            while data != b"" and self.playing:

                if not self.paused:
                    chunk_total += chunk
                    stream.write(data)
                    data = wf.readframes(chunk)
                    self.current_sec = chunk_total/wf.getframerate()

        self.playing=False
        stream.close()   
        p.terminate()

    def pause(self):
        self.paused = True
        
        if self.after_id:
            self.current_lbl.after_cancel(self.after_id)
            self.after_id = None
    
    def play(self):
        
        if not self.playing:
            self.playing = True
            threading.Thread(target=self.start_playing, daemon=True).start()
        
        if self.after_id is None:
            self.update_lbl()

        self.paused = False

    def stop(self):
        self.playing = False
        if self.after_id:
            self.current_lbl.after_cancel(self.after_id)
        self.after_id = None

    def update_lbl(self):
        
        self.current_lbl.config(text=f"{self.current_sec}/{self.audio_length}")
        self.after_id = self.current_lbl.after(5, self.update_lbl)


def handle_close():
    player.stop()
    root.destroy()

## SETUP AND RUN
root = tk.Tk()
player = SamplePlayer(root)

root.protocol("WM_DELETE_WINDOW", handle_close)
root.mainloop()

Upvotes: 2

Related Questions