9
\$\begingroup\$
import os, errno
import pyaudio
from scipy.signal import lfilter
import numpy
from tkinter import *
from tkinter.ttk import *
from tk_tools import *
from tkinter import messagebox
root=Tk()
root.title('Decibel Meter')
root.grid()
gaugedb = RotaryScale(root, max_value=120.0, unit=' dBA')
gaugedb.grid(column=1, row=1)
led = Led(root, size=50)
led.grid(column=3, row=1)
led.to_red(on=False)
Label(root, text='Too Loud').grid(column=3, row=0)
Label(root, text='Max').grid(column=2, row=0)
Label(root, text='Calibration (dB)').grid(column=4, row=0)
maxdb_display=SevenSegmentDigits(root, digits=3, digit_color='#00ff00', background='black')
maxdb_display.grid(column=2, row=1)
CHUNKS = [4096, 9600]
CHUNK = CHUNKS[1]
FORMAT = pyaudio.paInt16
CHANNEL = 1 
RATES = [44300, 48000]
RATE = RATES[1]
offset=StringVar()
offset.set('0')
spinbox=Spinbox(root, from_=-20, to=20, textvariable=offset, state='readonly')
spinbox.grid(column=4, row=1)
appclosed=False
from scipy.signal import bilinear
def close():
 global appclosed
 root.destroy()
 appclosed=True
 stream.stop_stream()
 stream.close()
 pa.terminate()
def A_weighting(fs):
 f1 = 20.598997
 f2 = 107.65265
 f3 = 737.86223
 f4 = 12194.217
 A1000 = 1.9997

 NUMs = [(2*numpy.pi * f4)**2 * (10**(A1000/20)), 0, 0, 0, 0]
 DENs = numpy.polymul([1, 4*numpy.pi * f4, (2*numpy.pi * f4)**2],
                [1, 4*numpy.pi * f1, (2*numpy.pi * f1)**2])
 DENs = numpy.polymul(numpy.polymul(DENs, [1, 2*numpy.pi * f3]),
                              [1, 2*numpy.pi * f2])
 return bilinear(NUMs, DENs, fs)
NUMERATOR, DENOMINATOR = A_weighting(RATE)
def rms_flat(a):
 return numpy.sqrt(numpy.mean(numpy.absolute(a)**2))
pa = pyaudio.PyAudio()
stream = pa.open(format = FORMAT,
             channels = CHANNEL,
             rate = RATE,
             input = True,
             frames_per_buffer = CHUNK)
def update_max_if_new_is_larger_than_max(new, max):
 if new > max:
     return new
 else:
     return max
def listen(old=0, error_count=0, min_decibel=100, max_decibel=0):
 global appclosed
 while True:
     try:
         try:
             block = stream.read(CHUNK)
         except IOError as e:
             if not appclosed:
                 error_count += 1
                 messagebox.showerror("Error, ", " (%d) Error recording: %s" % (error_count, e))
         else:
             decoded_block = numpy.fromstring(block, numpy.int16)
             y = lfilter(NUMERATOR, DENOMINATOR, decoded_block)
             new_decibel = 20*numpy.log10(rms_flat(y))+int(offset.get())
             old = new_decibel
             gaugedb.set_value(float('{:.2f}'.format(new_decibel)))
             max_decibel = update_max_if_new_is_larger_than_max(new_decibel, max_decibel)
             maxdb_display.set_value(str(int(float(str(max_decibel)))))
             if new_decibel>85:
                 led.to_red(on=True)
             else:
                 led.to_red(on=False)
         root.update()
     except TclError:
         break
root.protocol('WM_DELETE_WINDOW', close)
listen()

Is this an accurate dBA meter? My code works fine, but I want to check if it actually mirrors the ambient sound level from the microphone.

\$\endgroup\$
5
  • 10
    \$\begingroup\$ CodeReview can review your code, but to your main question of whether the psychoacoustics are accurate I'm sure something like physics, or maybe signal processing would be better-suited. Read their respective /help/on-topic guides first. \$\endgroup\$
    – Reinderien
    Commented Sep 25, 2022 at 21:18
  • \$\begingroup\$ See loosely related questions like dsp.stackexchange.com/questions/36077/… \$\endgroup\$
    – Reinderien
    Commented Sep 25, 2022 at 22:40
  • 6
    \$\begingroup\$ You should double check that sampling rate of 44300, that looks wrong (or at least non-standard). I’d expect 44100 in its place. \$\endgroup\$
    – Seb
    Commented Sep 26, 2022 at 9:52
  • 2
    \$\begingroup\$ Define 'accurate'. The only way to know whether a given instsrument meets a given degree of accuracy is to measure its error with a better instrument. Aside from finding problems which make it definitely not accurate, you are only going to prove that this is accurate if you can get to a real lab and measure real data. \$\endgroup\$
    – 2e0byo
    Commented Sep 27, 2022 at 12:19
  • \$\begingroup\$ (and to answer the chicken-and-egg problem raised: instruments are designed with others, and that, plus some theory and a lot of metaphysics, lets you ratchet your accuracy up by refining your notion of what you're measuring. See Hasok Chang inventing temperature for an entertaining discussion of how this works in practice) \$\endgroup\$
    – 2e0byo
    Commented Sep 27, 2022 at 12:20

1 Answer 1

31
\$\begingroup\$

Indent your code with a PEP8-compliant IDE or linter; it's a perfect mess right now.

Move your global code into functions and maybe classes. There are two good use cases for classes here - one for a GUI and one for an audio processor.

offset must not be a StringVar, but instead an IntVar - among other reasons this will obviate the cast in int(offset.get()). Do not leave it nameless and do not leave it orphaned; its parent needs to be the root object.

Move your import of bilinear up to join your other imports.

Your imports should avoid import *; that makes a vast swamp out of the global namespace and it doesn't need to be like that. Traditionally numpy is aliased to np.

Consider writing a context manager to close off your audio stream.

numpy.absolute(a)**2 is just a**2, right?

Delete update_max_if_new_is_larger_than_max. This is just a call to the built-in max().

Rather than

         if new_decibel>85:
             led.to_red(on=True)
         else:
             led.to_red(on=False)

just move the boolean expression to the argument of a single call and delete the if.

Add PEP484 typehints.

Convert your lists in A_weighting into immutable tuples.

Listen to the warnings being told to you: your use of np.fromstring needs to be replaced with np.frombuffer.

str(int(float(str(max_decibel)))) is just... majestic. Use a formatting string instead.

As @Seb comments, 44300 should almost certainly be 44100.

polymul is deprecated. Use Polynomial instead.

An equivalent to rms_flat is the more integrated and maybe faster

np.linalg.norm(a) / np.sqrt(len(a))

which, based on the linalg source, further reduces to a self-dot-product:

np.sqrt(a.dot(a) / len(a))

Suggested

import tkinter as tk
import numpy as np
import pyaudio
import tk_tools
from numpy.polynomial import Polynomial
from scipy.signal import bilinear, lfilter

CHUNKS = [4096, 9600]
CHUNK = CHUNKS[1]
FORMAT = pyaudio.paInt16
CHANNEL = 1
RATES = [44100, 48000]
RATE = RATES[1]


def A_weighting(fs: float) -> tuple[np.ndarray, np.ndarray]:
    f1 = 20.598997
    f2 = 107.65265
    f3 = 737.86223
    f4 = 12194.217
    a1000 = 1.9997

    nums = Polynomial(((2*np.pi * f4)**2 * 10**(a1000 / 20), 0,0,0,0))
    dens = (
        Polynomial((1, 4*np.pi * f4, (2*np.pi * f4)**2)) *
        Polynomial((1, 4*np.pi * f1, (2*np.pi * f1)**2)) *
        Polynomial((1, 2*np.pi * f3)) *
        Polynomial((1, 2*np.pi * f2))
    )
    return bilinear(nums.coef, dens.coef, fs)


def rms_flat(a: np.ndarray) -> float:
    return np.sqrt(a.dot(a) / len(a))


class Meter:
    def __init__(self) -> None:
        self.pa = pyaudio.PyAudio()
        self.stream = self.pa.open(
            format=FORMAT,
            channels=CHANNEL,
            rate=RATE,
            input=True,
            frames_per_buffer=CHUNK,
        )
        self.numerator, self.denominator = A_weighting(RATE)
        self.max_decibel = 0

    def __enter__(self) -> 'Meter':
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.stream.stop_stream()
        self.stream.close()
        self.pa.terminate()

    def listen(self, offset: int) -> float:
        block = self.stream.read(CHUNK)
        decoded_block = np.frombuffer(block, dtype=np.int16)
        y = lfilter(self.numerator, self.denominator, decoded_block)
        new_decibel = 20*np.log10(rms_flat(y)) + offset
        self.max_decibel = max(self.max_decibel, new_decibel)
        return new_decibel


class GUI:
    def __init__(self, meter: Meter) -> None:
        self.meter = meter

        self.root = root = tk.Tk()
        root.title('Decibel Meter')
        root.grid()
        root.protocol('WM_DELETE_WINDOW', self.close)
        self.app_closed = False

        self.gaugedb = tk_tools.RotaryScale(root, max_value=120, unit=' dBA')
        self.gaugedb.grid(column=1, row=1)

        self.led = tk_tools.Led(root, size=50)
        self.led.grid(column=3, row=1)
        self.led.to_red(on=False)

        tk.Label(root, text='Too Loud').grid(column=3, row=0)
        tk.Label(root, text='Max').grid(column=2, row=0)
        tk.Label(root, text='Calibration (dB)').grid(column=4, row=0)

        self.maxdb_display = tk_tools.SevenSegmentDigits(root, digits=3, digit_color='#00ff00', background='black')
        self.maxdb_display.grid(column=2, row=1)

        self.offset = tk.IntVar(root, value=0, name='offset')
        spinbox = tk.Spinbox(root, from_=-20, to=20, textvariable=self.offset, state='readonly')
        spinbox.grid(column=4, row=1)

    def close(self) -> None:
        self.app_closed = True

    def run(self) -> None:
        while not self.app_closed:
            new_decibel = self.meter.listen(self.offset.get())
            self.update(new_decibel, self.meter.max_decibel)
            self.root.update()

    def update(self, new_decibel: float, max_decibel: float) -> None:
        self.gaugedb.set_value(np.around(new_decibel, 1))
        self.maxdb_display.set_value(f'{max_decibel:.1f}')
        self.led.to_red(on=new_decibel > 85)


def main() -> None:
    with Meter() as meter:
        gui = GUI(meter)
        gui.run()


if __name__ == '__main__':
    main()

Output

screenshot of meter

Layout

Your layout needs a little love. Since the gauge text is at the bottom, why not put all labels at the bottom? Add some padding for legibility's sake, and add some resize sanity. Unfortunately, in addition to missing variable support, tk_tools widgets seem to have a broken layout behaviour because they ignore sticky resize requests; but oh well:

import tkinter as tk
import numpy as np
import pyaudio
import tk_tools
from numpy.polynomial import Polynomial
from scipy.signal import bilinear, lfilter

CHUNKS = [4096, 9600]
CHUNK = CHUNKS[1]
FORMAT = pyaudio.paInt16
CHANNEL = 1
RATES = [44100, 48000]
RATE = RATES[1]


def A_weighting(fs: float) -> tuple[np.ndarray, np.ndarray]:
    f1 = 20.598997
    f2 = 107.65265
    f3 = 737.86223
    f4 = 12194.217
    a1000 = 1.9997

    nums = Polynomial(((2*np.pi * f4)**2 * 10**(a1000 / 20), 0,0,0,0))
    dens = (
        Polynomial((1, 4*np.pi * f4, (2*np.pi * f4)**2)) *
        Polynomial((1, 4*np.pi * f1, (2*np.pi * f1)**2)) *
        Polynomial((1, 2*np.pi * f3)) *
        Polynomial((1, 2*np.pi * f2))
    )
    return bilinear(nums.coef, dens.coef, fs)


def rms_flat(a: np.ndarray) -> float:
    return np.sqrt(a.dot(a) / len(a))


class Meter:
    def __init__(self) -> None:
        self.pa = pyaudio.PyAudio()
        self.stream = self.pa.open(
            format=FORMAT,
            channels=CHANNEL,
            rate=RATE,
            input=True,
            frames_per_buffer=CHUNK,
        )
        self.numerator, self.denominator = A_weighting(RATE)
        self.max_decibel = 0

    def __enter__(self) -> 'Meter':
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.stream.stop_stream()
        self.stream.close()
        self.pa.terminate()

    def listen(self, offset: int) -> float:
        block = self.stream.read(CHUNK)
        decoded_block = np.frombuffer(block, dtype=np.int16)
        y = lfilter(self.numerator, self.denominator, decoded_block)
        new_decibel = 20*np.log10(rms_flat(y)) + offset
        self.max_decibel = max(self.max_decibel, new_decibel)
        return new_decibel


class GUI:
    def __init__(self, meter: Meter) -> None:
        self.meter = meter

        self.root = root = tk.Tk()
        root.title('Decibel Meter')
        root.grid()
        root.grid_rowconfigure(index=0, weight=1)
        root.grid_rowconfigure(index=1, weight=1)
        root.grid_columnconfigure(index=0, weight=1)
        root.grid_columnconfigure(index=3, weight=1)
        root.protocol('WM_DELETE_WINDOW', self.close)
        self.app_closed = False

        self.gaugedb = tk_tools.RotaryScale(root, max_value=120, unit=' dBA')
        # This control does not respect resizing via tk.NSEW.
        self.gaugedb.grid(row=0, column=0, rowspan=2, sticky=tk.E)

        self.maxdb_display = tk_tools.SevenSegmentDigits(root, digits=3, digit_color='#00ff00', background='black')
        self.maxdb_display.grid(row=0, column=1, sticky=tk.S, padx=5)
        tk.Label(root, text='Max').grid(row=1, column=1, sticky=tk.N, padx=5)

        self.led = tk_tools.Led(root, size=50)
        self.led.to_red(on=False)
        self.led.grid(row=0, column=2, sticky=tk.S, padx=5)
        tk.Label(root, text='Too Loud').grid(row=1, column=2, sticky=tk.N, padx=5)

        self.offset = tk.IntVar(root, value=0, name='offset')
        spinbox = tk.Spinbox(root, from_=-20, to=20, textvariable=self.offset, state='readonly', width=12)
        spinbox.grid(row=0, column=3, sticky=tk.SW, padx=5)
        tk.Label(root, text='Calibration (dB)').grid(row=1, column=3, sticky=tk.NW, padx=5)

    def close(self) -> None:
        self.app_closed = True

    def run(self) -> None:
        while not self.app_closed:
            new_decibel = self.meter.listen(self.offset.get())
            self.update(new_decibel, self.meter.max_decibel)
            self.root.update()

    def update(self, new_decibel: float, max_decibel: float) -> None:
        self.gaugedb.set_value(np.around(new_decibel, 1))
        self.maxdb_display.set_value(f'{max_decibel:.1f}')
        self.led.to_red(on=new_decibel > 85)


def main() -> None:
    with Meter() as meter:
        gui = GUI(meter)
        gui.run()


if __name__ == '__main__':
    main()

modified layout

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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