12
\$\begingroup\$

The past week I have tinkered making a sound visualizer using Tkinter, Matplotlib, NumPy, PyAudio and using a thread to be able to play the sound and to display the plot at the same time.
I have been coding Python now for almost two years and I think the program expresses my knowledge as to date.
I would appreciate reviews to make suggestions on what could be done better, quicker or more Pythonic. This to lead me to the right path in further developing my skills.

For a sample sound file you can download one at file examples

The code is a bit long (just under 500 lines of which more than two-thirds is for the Tkinter controls) but should just work by cut and paste as long as you have the modules for NumPy, Matplotlib, wave and PyAudio. Thanks!

import sys
import time
import threading
import re
import struct
from tkinter import (Tk, TclError, Frame, Label, Button,
                     Radiobutton, Scale, Entry, ttk,
                     filedialog, IntVar)
import wave
import numpy as np
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib import pyplot as plt
from matplotlib.animation import FuncAnimation
import pyaudio


DEFAULT_FREQUENCY = 223  # frequency in Hz
DEFAULT_DURATION = 5.0   # length of sound stream in seconds
INTERVAL = 100           # plot interval in millisecond
PACKAGE_LENGTH = 1024    # number of samples in sound package
VOLUME_RESOLUTION = 0.02 # resolution in volume scale (0 - 1)
FILE_SEARCH = r'^([a-zA-Z]:)?([/|\\].+[/|\\])*(.+$)'

PADY_OUTER = (2, 15)
PADY_INNER_1 = (2, 4)
PADY_INNER_2 = (0, 4)
PADY_INNER_3 = (0, 10)

class TkMplSetup:

    def __init__(self, root):
        self.root = root
        self.root.geometry('800x500')

        self.root.title("Sound Visualiser")
        self.root.columnconfigure(0, weight=1)

        self.volume = 0
        self.duration = DEFAULT_DURATION
        self.running = False
        self.stopped = True
        self.error_message = ''

        self.plot_area()
        self.main_buttons()
        self.control_buttons()

    def plot_area(self):
        plot_frame = Frame(self.root)
        plot_frame.grid(row=0, column=0, sticky='nw')

        self.fig, self.ax = plt.subplots(figsize=(5, 4))
        canvas = FigureCanvasTkAgg(self.fig, master=plot_frame)
        canvas.get_tk_widget().pack()

    def main_buttons(self):
        bottom_frame = Frame(self.root)
        bottom_frame.grid(row=1, column=0, rowspan=2, sticky='new')

        self.start_pause_button = Button(
            bottom_frame, text='Start', command=self.control_start_pause)
        self.start_pause_button.pack(side='left')

        self.stop_button = Button(
            bottom_frame, text='Stop', command=self.stop)
        self.stop_button.pack(side='left')

        self.quit_button = Button(
            bottom_frame, text='Quit', command=self.quit)
        self.quit_button.pack(side='left')

    def control_start_pause(self):
        if self.error_message:
            return

        if self.stopped:
            try:
                self.ax.lines.pop(0)

            except IndexError:
                pass

            if self.selected_type == 1:
                try:
                    self.frequency = int(self.frequency_entry.get())

                except ValueError:
                    self.frequency = DEFAULT_FREQUENCY
                    self.frequency_entry.insert(0, DEFAULT_FREQUENCY)

                except TclError:
                    self.frequency = DEFAULT_FREQUENCY

            if self.selected_type in [1, 2]:
                try:
                    self.duration = float(self.duration_entry.get())

                except ValueError:
                    self.duration = DEFAULT_DURATION
                    self.duration_entry.insert(0, DEFAULT_DURATION)

                except TclError:
                    self.duration = DEFAULT_DURATION

            # minus 1 is a correction to try to get the progress bar right,
            #under investigation
            self.time_progress['maximum'] = 1000 * (self.duration - 1.0)
            self.time_progress['value'] = 0
            self.running = True
            self.stopped = False
            self.start_pause_button.config(text='Pause')
            self.start_visualisation()
            return

        if self.running:
            self.visualisation.event_source.stop()
            self.pause_start_time = time.time()
            self.start_pause_button.config(text='Run')

        else:
            self.pause_time += time.time() - self.pause_start_time
            self.visualisation.event_source.start()
            self.start_pause_button.config(text='Pause')

        self.running = not self.running

    def stop(self):
        try:
            self.visualisation.event_source.stop()
        except AttributeError:
            pass

        self.stopped = True

    def quit(self):
        # add the delays for the processes to stop orderly
        # not sure if really required
        self.stop()
        # self.audio.terminate()  --> adding this line seems to crash the exit
        self.root.after(1, self.root.destroy)
        time.sleep(1)
        sys.exit()

    def control_buttons(self):
        self.control_frame = Frame(self.root)
        self.control_frame.grid(row=0, column=1, sticky='nw')

        self.control_wave_type()
        self.control_sampling_rate()
        self.control_volume_time()

        self.r_type.set(1)
        self.select_type()

    def control_wave_type(self):
        type_outer_frame = Frame(self.control_frame, bd=1, relief='groove')
        type_outer_frame.grid(row=0, column=0, sticky='ew', pady=PADY_OUTER)

        self.r_type = IntVar()

        Label(type_outer_frame, text='Sound type').grid(
            row=0, column=0, stick='w', pady=PADY_INNER_1)

        modes = {'note': 1, 'design': 2, 'file': 3}
        for i, (key, val) in enumerate(modes.items()):
            Radiobutton(type_outer_frame, text=key, width=6,
                        variable=self.r_type, value=val,
                        command=self.select_type).grid(
                            row=1, column=i, stick='w', pady=PADY_INNER_2)

        self.type_frame = Frame(type_outer_frame)
        self.type_frame.grid(
            row=2, column=0, columnspan=3, sticky='w', pady=PADY_INNER_3)

    def select_type(self):
        self.error_message = ''
        self.selected_type = self.r_type.get()

        if self.selected_type == 1:
            self.note_options()

        elif self.selected_type == 2:
            self.design_options()

        elif self.selected_type == 3:
            self.file_options()

        else:
            assert False, f'check selected_type invalid value {self.selected_type}'

        self.select_sampling_display()

    def note_options(self):
        for widget in self.type_frame.winfo_children():
            widget.destroy()

        Label(self.type_frame, text='Frequency').pack(side='left')
        self.frequency_entry = Entry(self.type_frame, width=5)
        self.frequency_entry.insert(0, DEFAULT_FREQUENCY)
        self.frequency_entry.pack(side='left')

        Label(self.type_frame, text='Duration').pack(side='left')
        self.duration_entry = Entry(self.type_frame, width=5)
        self.duration_entry.insert(0, DEFAULT_DURATION)
        self.duration_entry.pack(side='left')

    def design_options(self):
        for widget in self.type_frame.winfo_children():
            widget.destroy()

        Label(self.type_frame, text='Duration').pack(side='left')
        self.duration_entry = Entry(self.type_frame, width=5)
        self.duration_entry.insert(0, DEFAULT_DURATION)
        self.duration_entry.pack(side='left')

    def file_options(self):
        for widget in self.type_frame.winfo_children():
            widget.destroy()

        sound_file = filedialog.askopenfile(
            title='Select sound file',
            filetypes=(('wav files', '*.wav'), ('all files', '*')))

        try:
            file_name = 'Sound file: ' + re.search(
                FILE_SEARCH, sound_file.name).group(3) + '  '

        except AttributeError:
            file_name = '  '

        Label(self.type_frame, text=file_name).pack(anchor='w')

        if file_name:
            try:
                w = wave.open(sound_file.name)
            except (wave.Error, EOFError, AttributeError):
                self.error_message = 'Invalid wav file'
                Label(self.type_frame, text=self.error_message).pack(anchor='w')
                return

            frames = w.getnframes()
            channels = w.getnchannels()
            sample_width = w.getsampwidth()
            self.fs = w.getframerate()
            self.sound_byte_str = w.readframes(frames)
            self.duration = frames / self.fs * channels

            if sample_width == 1:
                self.fmt = f'{int(frames * channels)}B'

            else:
                self.fmt = f'{int(frames * channels)}h'

            print(f'frames: {frames}, channels: {channels}, '
                  f'sample width: {sample_width}, framerate: {self.fs}')

    def control_sampling_rate(self):
        sampling_outer_frame = Frame(self.control_frame, bd=1, relief='groove')
        sampling_outer_frame.grid(
            row=1, column=0, sticky='ew', pady=PADY_OUTER)

        Label(sampling_outer_frame, text='Sampling frequency').grid(
            row=0, column=0, stick='w', pady=PADY_INNER_1)

        self.sampling_frame = Frame(sampling_outer_frame)
        self.sampling_frame.grid(row=1, column=0, stick='w', pady=PADY_INNER_3)

    def select_sampling_display(self):
        if self.selected_type in [1, 2]:
            self.display_sampling_options()

        elif self.selected_type == 3:
            self.display_sampling_rate()

        else:
            assert False, f'control sampling rate for selected_type {self.selected_type}'

    def display_sampling_options(self):
        for widget in self.sampling_frame.winfo_children():
            widget.destroy()

        self.r_fs = IntVar()
        self.r_fs.set(12)
        self.select_fs()

        modes = {'2048': 11, '4096': 12, '8192': 13,
                 '16384': 14, '32768': 15}
        for i, (key, val) in enumerate(modes.items()):
            Radiobutton(self.sampling_frame, text=key, width=6,
                        variable=self.r_fs, value=val,
                        command=self.select_fs).grid(
                            row=int(i / 3), column=(i % 3), sticky='w')

    def select_fs(self):
        self.fs = 2**self.r_fs.get()
        self.ax.set_xlim(1000 * PACKAGE_LENGTH / self.fs, 0)
        self.fig.canvas.draw()

    def display_sampling_rate(self):
        for widget in self.sampling_frame.winfo_children():
            widget.destroy()

        Label(self.sampling_frame, text=f'Sampling rate: {self.fs} Hz').grid(
            row=0, column=0, stick='w')

        Label(self.sampling_frame, text=f'Duration: {self.duration:.1f} seconds').grid(
            row=1, column=0, stick='w')

    def control_volume_time(self):
        volume_outer_frame = Frame(self.control_frame, bd=1, relief='groove')
        volume_outer_frame.grid(
            row=2, column=0, sticky='ew', pady=PADY_OUTER)

        Label(volume_outer_frame, text='Volume').grid(
            row=0, column=0, stick='w', pady=PADY_INNER_1)

        volume_slider = Scale(volume_outer_frame,
                              from_=0, to_=1, resolution=VOLUME_RESOLUTION,
                              orient='horizontal',
                              command=self.set_volume,
                              showvalue=0,
                             )
        volume_slider.set(self.volume)
        volume_slider.grid(row=1, column=0, sticky='w', pady=PADY_INNER_3)

        Label(volume_outer_frame, text='Time').grid(
            row=0, column=1, stick='w', pady=PADY_INNER_1, padx=(20, 0))

        self.time_progress = ttk.Progressbar(volume_outer_frame,
                                             orient='horizontal',
                                             length=100,
                                             mode='determinate'
                                            )
        self.time_progress.grid(
            row=1, column=1, sticky='w', pady=PADY_INNER_3, padx=(20, 0))

    def set_volume(self, value):
        self.volume = float(value)


class SoundVisualiser(TkMplSetup):

    def __init__(self, root):
        super().__init__(root)
        self.audio = pyaudio.PyAudio()
        self.out = ''

    def generate_sound_stream(self):
        if self.selected_type == 1:
            self.sound_stream = (
                (np.sin(2 * np.pi * self.frequency / self.fs *
                        np.arange(self.fs * self.duration))).astype(np.float32)
            ).astype(np.float32)

        elif self.selected_type == 2:
            self.sound_stream = (
                (0.5 * np.sin(2 * np.pi * 325 / self.fs *
                              np.arange(self.fs * self.duration))) +
                (0.1 * np.sin(2 * np.pi * 330 / self.fs *
                              np.arange(self.fs * self.duration))) +
                (0.5 * np.sin(2 * np.pi * 340 / self.fs *
                              np.arange(self.fs * self.duration))) + 0
            ).astype(np.float32)

        elif self.selected_type == 3:
            a = struct.unpack(self.fmt, self.sound_byte_str)
            a = [float(val) for val in a]
            self.sound_stream = np.array(a).astype(np.float32)
            scale_factor = max(abs(np.min(self.sound_stream)),
                               abs(np.max(self.sound_stream)))
            self.sound_stream = self.sound_stream / scale_factor

        else:
            assert False, f'check selected_type invalid value {self.selected_type}'

        self.ax.set_xlim(1000 * PACKAGE_LENGTH / self.fs, 0)

    def callback(self, in_data, frame_count, time_info, status):
        self.out = self.sound_stream[:frame_count]
        self.sound_stream = self.sound_stream[frame_count:]
        return self.out*self.volume, pyaudio.paContinue

    def play_sound(self):
        self.stream = self.audio.open(format=pyaudio.paFloat32,
                                      channels=1,
                                      rate=self.fs,
                                      output=True,
                                      stream_callback=self.callback)

        self.stream.start_stream()

        # pause audio when self.running is False; close audio when stopped
        while self.stream.is_active():
            if self.stopped:
                break

            while not self.running:
                if self.stopped:
                    break

                if self.stream.is_active:
                    self.stream.stop_stream()
                else:
                    pass

            if self.running and not self.stream.is_active():
                self.stream.start_stream()

        self.stream.stop_stream()
        self.stream.close()

        self.running = False
        self.stopped = True
        self.start_pause_button.config(text='Start')

    def update_frame(self, frame):
        samples = len(self.out)
        if samples == PACKAGE_LENGTH:
            self.line.set_data(self.xdata, self.out)

        elif samples > 0:
            xdata = np.linspace(0, 1000 * samples / self.fs, samples)
            self.line.set_data(xdata, self.out)

        else:
            return

        elapsed_time = time.time() - self.start_time - self.pause_time
        self.time_progress['value'] = elapsed_time * 1000

        return self.line,

    def start_visualisation(self):
        self.generate_sound_stream()
        self.xdata = np.linspace(0, 1000 * PACKAGE_LENGTH / self.fs, PACKAGE_LENGTH)
        self.ax.set_ylim(1.1 * np.min(self.sound_stream),
                         1.1 * np.max(self.sound_stream))

        self.line, = self.ax.plot([], [], lw=2)

        duration_range = np.arange(0, self.duration, INTERVAL / 1000)
        self.start_time = time.time()
        self.pause_time = 0
        self.visualisation = FuncAnimation(self.fig,
                                           self.update_frame,
                                           frames=duration_range,
                                           interval=INTERVAL,
                                           repeat=False)

        # start audio deamon in a seperate thread as otherwise audio and
        # plot will not be at the same time
        x = threading.Thread(target=self.play_sound)
        x.daemon = True
        x.start()

        self.root.after(1, self.fig.canvas.draw())


def main():
    root = Tk()
    SoundVisualiser(root)
    root.mainloop()


if __name__ == '__main__':
    main()

enter image description here

\$\endgroup\$
6
\$\begingroup\$

Nice.

Here are some observations:

consider using an IntEnum:

This helps remove "magic" numbers from the source code. (A year from now, will you remember that if self.selected_type == 3 is a check to see if the mode is 'File'?)

from enum import auto, IntEnum

class SoundType(IntEnum):
    NOTE = auto()
    DESIGN = auto()
    FILE = auto()

Then later use:

for column, mode in enumerate(SoundType):
    Radiobutton(type_outer_frame,
                text=mode.name.lower(),
                width=6,
                variable=self.r_type,
                value=mode.value,
                command=self.select_type
                ).grid(
                    row=1, column=column, stick='w', pady=PADY_INNER_2)

or:

if self.selected_type in (SoundType.NOTE, SoundType.DESIGNS):
    self.display_sampling_options()

Use pathlib to get the filename

from pathlib import Path


sound_file = filedialog.askopenfile(
    title='Select sound file',
    filetypes=(('wav files', '*.wav'), ('all files', '*')))

file_name = Path(sound_file.name).name

document

I would document why you are ignoring an exception

def stop(self):
    try:
        self.visualisation.event_source.stop()

    except AttributeError:
        # this exception can be ignored because ...
        pass

    self.stopped = True

assert False, f'..message..'

I've not seen this used before, and it seems odd to me. I guess it makes sure you don't forget to add an elif block if you add a new mode, but let's you turn off the assertion. I think I'd just raise RuntimeError or maybe NotImplementedError.

\$\endgroup\$
  • \$\begingroup\$ This is indeed an improper use of assert. The entire select_type could be written clearer, but my experience with tkinter is insufficient to see what the best method would be. Perhaps by letting all buttons write to the same variable, check its value to an enum or dictionary and map an action to it in there. Wrap in a try/catch, if it unsuccessfully resolves there's a bug and the program should probably terminate. \$\endgroup\$ – Mast Nov 6 at 7:01
  • \$\begingroup\$ @RootTwo, thank for the feedback, especially IntNum and pathlib are useful. The intent of the asserts is to capture the event something is selected not covered in the elif's. Should I just do else: raise RuntimeError or NotImplementedError? \$\endgroup\$ – Bruno Vermeulen Nov 6 at 7:21
  • \$\begingroup\$ @BrunoVermeulen, select_type is only set by the buttons. So, the else-clause should never get run unless something is seriously broken. If this code is for personal use I would just raise a Runtime error. If it is shared, I would log the error and try to recover (set select_type to an allowed value). \$\endgroup\$ – RootTwo Nov 6 at 14:35
  • \$\begingroup\$ @BrunoVermeulen Note also that assert is only for development use. For example when code is compiled for performance, the assert statements get ignored (see also). \$\endgroup\$ – Ivo Merchiers Nov 7 at 8:30
3
\$\begingroup\$

In addition to what RootTwo already mentioned, I would also advise to:

Define all class attributes in __init__

When I want to understand a new class, the first things that I look at are its methods and its attributes. These are luckily already well-named, so I get an intuitive understanding of what they mean.

However, your attributes are spread out over your class. I need to read through all the code to even see how many attributes there are. This problem will also be picked up by some linters, as discussed in this post. The answers already suggest two solutions (slightly adapted here):

You may still want to split initialization into other methods though. In such case, you can simply assign attributes to None (with a bit of documentation) in the init then call the sub-initialization methods.

or

Just return an value(or tuple of values) and unpack into attributes inside init as needed.

Whatever you use is up to you, but personally I prefer the second option, since that makes it more obvious when reading the __init__ that a value is set (to follow POSA).

In this case, you might argue that this requires a large amount of typing and variable assignments. However, this leads me to the second point:

Try to refactor some attributes

You have a very large number of attributes, of which a lot seem closely related. This is more natural when dealing with GUI stuff, but I would still try to organize some of them in data structures such as:

  • Dictionaries
  • Dataclasses (if your version of Python allows it)

This will also make it much easier to reuse and update portions of your code, since it exactly defines what should minimally be initialized.


Side Note:

Defining all variables in __init__ might feel as boilerplate code. In some situations using the attrs package can reduce this work. It also comes with some nice benefits such as providing a nice automated string repr and comparison methods for your classes.

I'm not sure if it is perfect for this situation, but I find it a handy tool to know about, that can make some classes a lot clearer.

\$\endgroup\$
  • \$\begingroup\$ Thanks for your input. So that means I have to declare anything that has a self. in front in the __init__, like buttons. frames, labels, variable names? I am not to sure about the return of a tuple method. I should be able to work on Dataclasses as I am currently on python 3.7.4 \$\endgroup\$ – Bruno Vermeulen Nov 7 at 13:52
  • \$\begingroup\$ @BrunoVermeulen Yes, that's the general convention. Tbh, in some situations, you can stray from that pattern, but since this is Code Review, I wanted to mention it. I also added a small side note about the attrs package, which can make that a bit easier. Yeah, returning a tuple doesn't necessarily make sense, that was copied from somewhere else. I have changed it. \$\endgroup\$ – Ivo Merchiers Nov 7 at 14:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.