Python and QA40x

There are some Python classes I’m finishing that know how to use the calibration data inside the QA40x so that you can send/receive absolute values to/from the QA40x hardware. This will come in subsequent post. This post is a continuation of the study in the post linked below.

The focus for this first post is on DSP first principles in Python so that you can round-trip a waveform in Python and have it make sense. It’s pretty easy to generate a sine and see the spectrum. But there’s a lot of confusion around how to make noise measurements and amplitude measurements that make sense AND have those measurements make sense when you change FFT sizes. So, for starters here’s a simple framework helping to understand this in Python. I’m not a Python guy, but with ChatGPT I can muddle through. Feel free to point out improvements.

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal.windows import flattop, boxcar, hamming
from scipy.fft import rfft, rfftfreq

# Define the parameters
N = 4096
fs = 48000  # Sampling frequency in Hz
bin_resolution = fs / N
f_target = 1000
f_adjust_strategy = 'snap'  # Set to 'none', 'snap', or 'straddle'

# Adjust f_target based on the provided strategy
if f_adjust_strategy == 'snap':
    f = round(f_target / bin_resolution) * bin_resolution
elif f_adjust_strategy == 'straddle':
    bin_index = round(f_target / bin_resolution)
    f = (bin_index + 0.5) * bin_resolution
else:
    f = f_target

t = np.arange(N) / fs  # Time vector

# Create a sinusoidal signal with a peak amplitude of 1.41 = 0 dBV
amplitude = 1.41
signal = amplitude * np.sin(2 * np.pi * f * t)

# Choose the window function
window_function = 'hamming'  # Change to 'flattop', 'boxcar', or 'hamming'

if window_function == 'flattop':
    window = flattop(N)
elif window_function == 'boxcar':
    window = boxcar(N)
elif window_function == 'hamming':
    window = hamming(N)
else:
    raise ValueError("Unknown window function")

# Compute the Amplitude Correction Factor (ACF). This factor is used to correct the
# FFT results so that the amplitudes are displayed as RMS (Root Mean Square) values.
# The ACF ensures that the peak amplitude of a 0 dBV sine wave (for example) will 
# be accurately represented in the frequency domain plot.
mean_w = np.mean(window)
ACF = 1 / mean_w

# Compute the Energy Correction Factor (ECF). This factor is used to ensure accurate
# energy calculations when integrating across the frequency domain. In the time domain,
# the RMS (Root Mean Square) value of a signal can be calculated directly using the formula:
# RMS = sqrt(mean(signal**2)). However, in the frequency domain, to accurately calculate
# the RMS energy over a range of frequencies, we need to apply the ECF
rms_w = np.sqrt(np.mean(window**2))
ECF = 1 / rms_w

# Print the window function, correction factors, frequencies, and bin size
print(f"Window function: {window_function}")
print(f"Amplitude Correction Factor (ACF): {ACF:.4f} ({20 * np.log10(ACF):.2f} dB)")
print(f"Energy Correction Factor (ECF): {ECF:.4f} ({20 * np.log10(ECF):.2f} dB)")
print(f"f_target: {f_target} Hz")
print(f"f (adjusted): {f} Hz")
print(f"Bin size: {bin_resolution:.2f} Hz")
print(f"Frequency adjustment strategy: {f_adjust_strategy}")

# Apply the selected window
windowed_signal = signal * window

# Perform the FFT and normalize. Normalizing means that, regardless of the FFT size, 
# we'll still arrive at the same amplitude for a given time-domain signal Note the rfft
# assumes real input data, and computes only the positive freq components
fft_result = rfft(windowed_signal)
fft_normalized = (np.abs(fft_result) / (N / 2)) / np.sqrt(2)

# Apply the ACF to the entire FFT magnitude. With this step, a 0 dBV sine will have
# a peak of 0 dBV
fft_magnitude_acf = fft_normalized * ACF
fft_magnitude_acf_dBV = 20 * np.log10(fft_magnitude_acf)  # Convert to dBV

# Compute the frequencies corresponding to the positive half of the FFT
frequencies = rfftfreq(N, d=1/fs)

# Find the peak in the corrected FFT magnitude
peak_index = np.argmax(fft_magnitude_acf)  # Consider only the positive frequencies
plot_peak_frequency = peak_index * bin_resolution
peak_amplitude = fft_magnitude_acf[peak_index]
peak_amplitude_dBV = 20 * np.log10(peak_amplitude)

# Compute the RMS energy of the entire spectrum (excluding DC and Nyquist bins)
rms_energy_entire_spectrum = np.sqrt(np.sum(fft_normalized[1:]**2)) * ECF
rms_energy_entire_spectrum_dBV = 20 * np.log10(rms_energy_entire_spectrum)

# Compute the RMS energy from start frequency to stop frequency
start_freq = 900
stop_freq = 1100

# Find the indices corresponding to start and stop frequencies
start_index = np.searchsorted(frequencies, start_freq, side='left')
stop_index = np.searchsorted(frequencies, stop_freq, side='right')

# Compute the RMS energy within the specified frequency range
rms_energy_range = np.sqrt(np.sum(fft_normalized[start_index:stop_index]**2)) * ECF
rms_energy_range_dBV = 20 * np.log10(rms_energy_range)

# Print frequency domain peak values
print(f"Plot Peak Frequency: {plot_peak_frequency:.2f} Hz")
print(f"Plot Peak Amplitude: {peak_amplitude:.4f} ({peak_amplitude_dBV:.2f} dBV)")
print(f"RMS Energy of the Entire Spectrum (excluding DC and Nyquist): {rms_energy_entire_spectrum:.4f} ({rms_energy_entire_spectrum_dBV:.2f} dBV)")
print(f"RMS Energy from {start_freq} Hz to {stop_freq} Hz: {rms_energy_range:.4f} ({rms_energy_range_dBV:.2f} dBV)")

# Plot the window function, signal, and its FFT
plt.figure(figsize=(12, 6))

# Plot the window function
plt.subplot(2, 2, 1)
plt.plot(t, window, label='Window Function')
plt.xlabel('Time [s]')
plt.ylabel('Amplitude')
plt.title('Window Function')
plt.legend()
plt.grid()

# Plot the time domain signal
plt.subplot(2, 2, 2)
plt.plot(t, signal, label='Original Signal')
plt.plot(t, windowed_signal, label='Windowed Signal')
plt.xlabel('Time [s]')
plt.ylabel('Amplitude')
plt.title('Time Domain Signal')
plt.legend()
plt.grid()

# Plot the FFT magnitude
plt.subplot(2, 2, 3)
ax3 = plt.gca()
ax3.plot(frequencies, fft_magnitude_acf, label='Magnitude', color='b')
ax3.set_xlabel('Frequency [Hz]')
ax3.set_ylabel('Magnitude')
ax3.set_title('Frequency Domain (FFT)')
ax3.grid()

# Add a secondary y-axis to show dBV
ax3_right = ax3.twinx()
ax3_right.plot(frequencies, fft_magnitude_acf_dBV, color='r', alpha=0.5, linestyle='--', label='Magnitude (dBV)')
ax3_right.set_ylabel('Magnitude (dBV)')
ax3_right.grid(False)

# Combine legends
lines, labels = ax3.get_legend_handles_labels()
lines2, labels2 = ax3_right.get_legend_handles_labels()
ax3_right.legend(lines + lines2, labels + labels2, loc='upper right')

# Plot a zoomed-in view of +/- 10 bins from the center frequency
plt.subplot(2, 2, 4)
ax4 = plt.gca()
zoom_start = max(0, peak_index - 10)
zoom_end = min(len(frequencies), peak_index + 11)
ax4.plot(frequencies[zoom_start:zoom_end], fft_magnitude_acf[zoom_start:zoom_end], label='Magnitude', color='b')
ax4.set_xlabel('Frequency [Hz]')
ax4.set_ylabel('Magnitude')
ax4.set_title(f'Zoomed-in View (+/- 10 bins) around {plot_peak_frequency:.2f} Hz')
ax4.grid()

# Add a secondary y-axis to show dBV
ax4_right = ax4.twinx()
ax4_right.plot(frequencies[zoom_start:zoom_end], fft_magnitude_acf_dBV[zoom_start:zoom_end], color='r', alpha=0.5, linestyle='--', label='Magnitude (dBV)')
ax4_right.set_ylabel('Magnitude (dBV)')
ax4_right.grid(False)

# Combine legends
lines, labels = ax4.get_legend_handles_labels()
lines2, labels2 = ax4_right.get_legend_handles_labels()
ax4_right.legend(lines + lines2, labels + labels2, loc='upper right')

plt.tight_layout()
plt.show()

The code above can be run in JupyterLab, and the following output will show:

Note in the above we’re using a hamming window with a frequency adjustment strategy of “snap”. There are 3 frequency adjustment strategies in the code:

none When this is selected, the f_target you select will be the frequency that is used for generating a sine.

snap When this is selected, the f_target you select will be nudged slighted such that the frequency falls in the center of an fft bin. This is ideal for signal processing, because it usually means you don’t need to use a window.

straddle When this is selected, the f_target will be nudged so that the frequency falls between two bins. This is the often the worst-case for dealing with amplitude issues related to windowing. We’ll see this in more detail below.

In the first example we ran, a snap strategy was used, which means the frequency was adjusted from our target of 1000 Hz to 996.09375 Hz. This adjustment put the tone right in the middle of a bin. Note in the Zoomed-in View how the peak is precisely 1.0 Vrms (our source sine was a 1.41V peak, which is 1.0Vrms. So, our frequency domain graphs are accurately reflecting the rms of the sine waves.

Now we can change the Window to boxcar (rectangle) and see how the output changes:

By and large, it looks very similar. And this is expected, because when your signal falls in the middle of a bin, your amplitude errors are minimized.

Now, let’s change the adjustment strategy to ```straddle`` and run it again:

Hmmm. Now we’ve got a problem. The peak shown in the frequency domain looks very low. And instead of a single peak in the zoomed-in view, we now have two peaks. Since we asked for a tone to be placed between two bins, this makes sense. Each bin will have half the energy. Finally, notice the skirts. At 1050 Hz, we can the amplitude is quite high–around -23 dBV.

Now let’s switch the window to flattop:

This is better! Notice the amplitudes in the freq plots are back to 0 dBV and the skirts are also improved.

RMS Energy Measurements

The trials above include RMS energy measurements. These are measurements made on the spectral data. It’s well known how to compute RMS from the time domain series. But it’s very useful to compute RMS from a spectrum too. Notice that the RMS measurements are much less sensitive to window selection. When boxcar/straddle is used, there’s a bit of error in the RMS energy calc due to the skirts being high outside the of the 900 to 1100 Hz. But that could be corrected by opening the window a bit. When the entire spectrum is considered, the RMS is correct regardless of tone frequency and/or window type.

ACF and ECF

In the code, note that we have two adjustments that need to be made to the raw spectrum data that is returned by the rfft function. The first is Amplitude Correction Factor, or ACF. The second is Energy Correction Factor or ECF.

A way to think about this is as follows: The ACF must be applied to data you want to plot, and the ECF must be applied to data you want to integrate (to learn RMS, for example).

Thankfully, both can be learned from the window shape with some simple math.

# Compute the Amplitude Correction Factor (ACF). This factor is used to correct the
# FFT results so that the amplitudes are displayed as RMS (Root Mean Square) values.
# The ACF ensures that the peak amplitude of a 0 dBV sine wave (for example) will 
# be accurately represented in the frequency domain plot.
mean_w = np.mean(window)
ACF = 1 / mean_w

# Compute the Energy Correction Factor (ECF). This factor is used to ensure accurate
# energy calculations when integrating across the frequency domain. In the time domain,
# the RMS (Root Mean Square) value of a signal can be calculated directly using the formula:
# RMS = sqrt(mean(signal**2)). However, in the frequency domain, to accurately calculate
# the RMS energy over a range of frequencies, we need to apply the ECF
rms_w = np.sqrt(np.mean(window**2))
ECF = 1 / rms_w

Adding Another Tone

We can tweak the code above to add another tone that is 3X the adjusted frequency at an amplitude that is 1/100th the indicated amplitude (-40 dB below the fundamental).

# Create a sinusoidal signal with a peak amplitude of 1.41 = 0 dBV
amplitude = 1.41
signal = amplitude * np.sin(2 * np.pi * f * t)

# Add a secondary frequency component at 3 times the target frequency with 1/100 the amplitude
secondary_frequency = 3 * f
secondary_amplitude = amplitude / 100
signal += secondary_amplitude * np.sin(2 * np.pi * secondary_frequency * t)

We can see the added tone appear in the frequency domain at the -40 dBV level:

Summary

It can be very challenging to get FFT data that matches expectations as you change FFT size, window types and signal frequency. The code above makes it easy to adjust key parameters and see how the results match your expectations.

Next, we’ll pull in the some classes that can handle the various calibrations required to get absolute data in and out of the QA40x hardware.

1 Like

Wow this is great Matt! I’ve been using the previous code without calibration, it really enables some useful testing of ideas. I’m a Python guy, I can suggest some things :slight_smile: I wanted to reach out and ask about the calibration, I took a look last time we spoke, but didn’t get too far!

Hi @Dan, hopefully it’ll be up in a repo in the next few days and then improvements and corrections will be easy. The graphing ability in Python alone is worth the pain :wink:

What is your view on type hints in python? Probably not too useful for personal code projects. But how about for libs?

Coming from a C world before Python, I quite like type hint feature. I would definitely use it when other people need to use my code or if I am doing something moderately complicated that I want to understand 6 months down the line.

One of the traps with Python is writing code that is so dynamic it can only understood when running it!

One of the traps with Python is writing code that is so dynamic it can only understood when running it!

:wink:

Repo is up here. I’ll get code in to measure THD and THDN in the next few days.

Here’s a standalone program for looking at energy decay in a room. There are just two calls to the QA40x Python analyzer code: The init() and the sendreceive() for getting the calibrated time-domain data in and out of the QA403. The rest is handled in the code below. Much of the code below doesn’t care about the absolute calibration, except for the instantaneous RMS estimate. I used an SM58 in the home office, and should have used the Earthworks M23R. But it was at work.

I used GPT a lot for this code. You can tell it to study a git repo (such as PyQa40x) and use that as a starting point. And it’s pretty good in that regard. What you get from 250 lines of Python blows my mind. I know, I’m late to the party :wink:

We are very close to being able to ask ChatGPT “I need some software to figure out the gain of this amp, and then plot THD versus power out making sure to not exceed 1% THD at any point. Use a 32K FFT with a rect window.” Anoterh 6-12 months of GPT evolution and some more samples in the repo will dramatically lower the bar for writing custom code for both testing and algo development.

And as a partner for developing audio algorithms, this exercise with gpt has really impressed me. GPT iterating on the code, pasting it into Jupyter, running, pasting the plots back to gpt and asking “what went wrong” is really, really good. I can only imagine where this goes when GPT is more integrated with an environment like Jupyter or VS.

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import fftconvolve, spectrogram
from mpl_toolkits.mplot3d import Axes3D
from PyQa40x.analyzer import Analyzer

# Test Setup: QA403 drives QA462 whcih drives speaker which
# fires into a room about 13x16x10'. The room sweep is picked
# up by an SM58, amplified 20 dB by a QA472 and then recovered
# by the QA403

# Key setup vars
fft_size = 2 ** 17  # Buffer length in samples
chirp_amplitude_dbv = -10  # Chirp amplitude in dBV. QA462 has 20 dB of gain
sample_rate = 48000

# Define mic sensitivity in dBV/Pa and external pre-amp gain
mic_sensitivity_dbv_pa = -54.5  # Sure SM58
pre_amp_gain_db = 20  # QA472 20 dB
rms_slice_interval_ms = 10  # RMS calculation interval in ms

# Convert mic sensitivity from dBV/Pa to V/Pa
mic_sensitivity_v_pa = 10 ** (mic_sensitivity_dbv_pa / 20.0)

def farina_chirp(total_buffer_length, f1, f2, fs, chirp_amplitude_dbv):
    # Chirp lives in 60% of buffer. The remaining 40% will hold the room reflections.
    T = (total_buffer_length * 0.6) / fs
    t = np.arange(0, T, 1 / fs)
    R = np.log(f2 / f1)

    # Convert chirp amplitude from dBV to Vrms and then to peak value
    V_rms = 10 ** (chirp_amplitude_dbv / 20.0)
    peak = V_rms * np.sqrt(2)
    chirp_signal = peak * np.sin((2 * np.pi * f1 * T / R) * (np.exp(t * R / T) - 1))

    # Pad the signal to the total buffer length with silence at the end
    padding = total_buffer_length - len(chirp_signal)
    padded_chirp = np.pad(chirp_signal, (0, padding), 'constant')
    padded_t = np.arange(0, len(padded_chirp)) / fs

    # Generate the inverse filter
    k = np.exp(padded_t * R / T)
    inverse_filter = padded_chirp[::-1] / k

    return padded_chirp, inverse_filter

def calculate_rt(ir, sample_rate, decay_db):
    squared_ir = ir ** 2
    edc = np.cumsum(squared_ir[::-1])[::-1]
    edc_db = 10 * np.log10(edc / np.max(edc))
    
    start_idx = np.where(edc_db <= -5)[0][0]
    end_idx = np.where(edc_db <= -5 - decay_db)[0][0]
    
    slope, intercept = np.polyfit(np.arange(start_idx, end_idx) / sample_rate, edc_db[start_idx:end_idx], 1)
    rt = -(decay_db + 5) / slope
    return rt, edc_db, start_idx, end_idx

def compute_instantaneous_dbspl(signal, sample_rate, mic_sensitivity_v_pa, pre_amp_gain_db, rms_slice_interval_ms):
    segment_duration = rms_slice_interval_ms / 1000.0  # Convert to seconds
    segment_samples = int(segment_duration * sample_rate)
    
    pre_amp_gain_linear = 10 ** (pre_amp_gain_db / 20.0)
    
    dbspl_values = []
    for start in range(0, len(signal), segment_samples):
        segment = signal[start:start + segment_samples]
        if len(segment) == 0:
            break
        
        rms_value = np.sqrt(np.mean(segment**2))
        
        # Convert the RMS value to the sound pressure level in Pascals
        p_acquired = rms_value / pre_amp_gain_linear / mic_sensitivity_v_pa
        
        # Convert the sound pressure level to dB SPL
        db_spl = 20 * np.log10(p_acquired / 20e-6)
        dbspl_values.append(db_spl)
    
    time_values = np.linspace(0, len(signal) / sample_rate, len(dbspl_values))
    return np.array(dbspl_values), time_values

def generate_window(buffer_size, window_start, window_end, ramp_up, ramp_down):
    small_value = 0.000001
    
    buffer = np.full(buffer_size, small_value)
    
    flat_top_length = max(0, window_end - window_start - ramp_up - ramp_down)
    
    if ramp_up > 0:
        ramp_up_window = np.hanning(2 * ramp_up)[:ramp_up]
    else:
        ramp_up_window = np.array([])
    
    if ramp_down > 0:
        ramp_down_window = np.hanning(2 * ramp_down)[ramp_down:]
    else:
        ramp_down_window = np.array([])
    
    window_function = np.concatenate([
        ramp_up_window,
        np.ones(flat_top_length),
        ramp_down_window
    ])
    
    buffer[window_start:window_start + len(window_function)] = window_function
    
    return buffer

analyzer = Analyzer()
params = analyzer.init(sample_rate=sample_rate, max_input_level=0, max_output_level=18, fft_size=fft_size, window_type='hamming')

chirp, inv_filter = farina_chirp(fft_size, 20, 20000, params.sample_rate, chirp_amplitude_dbv)

left_dac_full_buffer = chirp
right_dac_full_buffer = left_dac_full_buffer

left_adc_full_buffer, right_adc_full_buffer = analyzer.send_receive(left_dac_full_buffer, right_dac_full_buffer)

# done with analyzer
analyzer.cleanup()

# Get the data buffer and remove DC
main_adc_buffer = left_adc_full_buffer.get_main_buffer()
main_adc_buffer = main_adc_buffer - np.mean(main_adc_buffer)

# Get the room impulse response from the ADC time domain data
ir_data = fftconvolve(main_adc_buffer, inv_filter, mode='same')

# Center IR peak in window
peak_index = np.argmax(np.abs(ir_data))
centered_ir_data = np.roll(ir_data, len(ir_data) // 2 - peak_index)

# Build a window to apply to the IR
buffer_size = len(centered_ir_data)
window_start = len(centered_ir_data) // 2 - 1000
window_width = buffer_size // 2
window_end = window_start + window_width
ramp_up = 500
ramp_down = 1000

window = generate_window(buffer_size, window_start, window_end, ramp_up, ramp_down)
windowed_ir_data = centered_ir_data * window

# Compute decay times
rt60, edc_db, start_idx, end_idx = calculate_rt(windowed_ir_data, sample_rate, decay_db=60)
rt40, _, _, _ = calculate_rt(windowed_ir_data, sample_rate, decay_db=40)
rt30, _, _, _ = calculate_rt(windowed_ir_data, sample_rate, decay_db=30)
rt20, _, _, _ = calculate_rt(windowed_ir_data, sample_rate, decay_db=20)
rt10, _, _, _ = calculate_rt(windowed_ir_data, sample_rate, decay_db=10)
rt5, _, _, _ = calculate_rt(windowed_ir_data, sample_rate, decay_db=5)

time = np.arange(len(left_dac_full_buffer)) / sample_rate

# Compute dBSPL on ADC data
dbspl_values, dbspl_time = compute_instantaneous_dbspl(main_adc_buffer, sample_rate, mic_sensitivity_v_pa, pre_amp_gain_db, rms_slice_interval_ms)

plt.figure(figsize=(15, 25))

plt.subplot(6, 1, 1)
plt.plot(time, left_dac_full_buffer)
plt.title("DAC Data")
plt.xlabel("Time (s)")
plt.ylabel("Amplitude")

plt.subplot(6, 1, 2)
plt.plot(time[:len(main_adc_buffer)], main_adc_buffer)
plt.title("ADC Data")
plt.xlabel("Time (s)")
plt.ylabel("Amplitude")

plt.subplot(6, 1, 3)
plt.plot(dbspl_time, dbspl_values)
plt.title("ADC Instantaneous dB SPL")
plt.xlabel("Time (s)")
plt.ylabel("dB SPL")
plt.grid(True, which='both', axis='both')
plt.grid(which='major', linestyle='-', linewidth='0.5', color='black')
plt.grid(which='minor', linestyle=':', linewidth='0.5', color='black')
plt.minorticks_on()
plt.gca().xaxis.set_major_locator(plt.MultipleLocator(0.25))
plt.gca().xaxis.set_minor_locator(plt.MultipleLocator(0.05))
plt.gca().yaxis.set_major_locator(plt.MultipleLocator(10))
plt.gca().yaxis.set_minor_locator(plt.MultipleLocator(2))

plt.subplot(6, 1, 4)
ax1 = plt.gca()
ax2 = ax1.twinx()
ax1.plot(time[:len(centered_ir_data)], centered_ir_data, label='IR Data', color='b')
ax2.plot(time[:len(window)], window, label='Window', color='r')
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("Amplitude (IR Data)")
ax2.set_ylabel("Amplitude (Window)")
ax1.set_title("Impulse Response (IR) with Window")
ax1.legend(loc='upper left')
ax2.legend(loc='upper right')

edc_time = np.arange(len(edc_db)) / sample_rate
plt.subplot(6, 1, 5)
plt.plot(edc_time, edc_db)
plt.title("Energy Decay Curve (EDC)")
plt.xlabel("Time (s)")
plt.ylabel("EDC (dB)")
plt.axvline(x=(start_idx + np.where(edc_db[start_idx:] <= -5)[0][0]) / sample_rate, color='r', linestyle='--', label='-5 dB')
plt.axvline(x=(start_idx + np.where(edc_db[start_idx:] <= -10)[0][0]) / sample_rate, color='g', linestyle='--', label='-10 dB')
plt.axvline(x=(start_idx + np.where(edc_db[start_idx:] <= -20)[0][0]) / sample_rate, color='b', linestyle='--', label='-20 dB')
plt.axvline(x=(start_idx + np.where(edc_db[start_idx:] <= -40)[0][0]) / sample_rate, color='m', linestyle='--', label='-40 dB')
plt.axvline(x=(start_idx + np.where(edc_db[start_idx:] <= -60)[0][0]) / sample_rate, color='y', linestyle='--', label='-60 dB')
plt.legend()
plt.ylim(-75, 5)
plt.grid(True, which='both', axis='both')
plt.grid(which='major', linestyle='-', linewidth='0.5', color='black')
plt.grid(which='minor', linestyle=':', linewidth='0.5', color='black')
plt.minorticks_on()
plt.gca().xaxis.set_major_locator(plt.MultipleLocator(0.25))
plt.gca().xaxis.set_minor_locator(plt.MultipleLocator(0.05))
plt.gca().yaxis.set_major_locator(plt.MultipleLocator(20))
plt.gca().yaxis.set_minor_locator(plt.MultipleLocator(5))

# Compute the spectrogram
frequencies, times, Sxx = spectrogram(windowed_ir_data, fs=sample_rate, nperseg=1024, noverlap=512)

# Convert power spectrogram to dB scale
Sxx_dB = 10 * np.log10(Sxx)

# Normalize the spectrogram so the maximum intensity is 0 dB
Sxx_dB -= np.max(Sxx_dB)

# Plot Spectrogram
plt.subplot(6, 1, 6)
plt.pcolormesh(times, frequencies, Sxx_dB, shading='gouraud', cmap='inferno', vmin=-100, vmax=0)
plt.title("Normalized Spectrogram of Impulse Response")
plt.xlabel("Time (s)")
plt.ylabel("Frequency (Hz)")
plt.colorbar(label='Intensity (dB)')
plt.ylim(20, 20000)  # Set the y-axis range to 20 Hz to 20 kHz
plt.grid(True, which='both', axis='both')  # Enable grid lines

plt.tight_layout()
plt.show()

print(f"RT5: {rt5:.2f} seconds ({5 / rt5:.2f} dB/sec)")
print(f"RT10: {rt10:.2f} seconds ({10 / rt10:.2f} dB/sec)")
print(f"RT20: {rt20:.2f} seconds ({20 / rt20:.2f} dB/sec)")
print(f"RT30: {rt30:.2f} seconds ({30 / rt30:.2f} dB/sec)")
print(f"RT40: {rt40:.2f} seconds ({40 / rt40:.2f} dB/sec)")
print(f"RT60: {rt60:.2f} seconds ({60 / rt60:.2f} dB/sec)")

1 Like

@matt just as A side note to your ai code development, try Continue extension in VS code with gpt4

Hello @Moto, Just watched some videos, looks very good thanks!