Audio Frequency Spectrum Visualizer

 """

Audio Frequency Spectrum Visualizer (Tkinter + matplotlib)


- Select a WAV file

- Shows waveform (top) and animated FFT spectrum bars (bottom)

- Works with mono or stereo WAV files

- Uses scipy.io.wavfile to read WAV

- Animation scrolls through the audio buffer, computing FFT per frame to simulate live visualization


Limitations:

- Only WAV supported out of the box. For MP3, use pydub to convert to raw samples (instructions below).

- No audio playback included by default (keeps dependencies minimal). See comments for how to add playback.

"""


import tkinter as tk

from tkinter import ttk, filedialog, messagebox

import numpy as np

import matplotlib

matplotlib.use("TkAgg")

import matplotlib.pyplot as plt

from matplotlib.animation import FuncAnimation

from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

from scipy.io import wavfile

import os


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

# Visualization settings

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

FRAME_SIZE = 2048        # samples per frame for FFT (power of two recommended)

HOP_SIZE = 1024          # step between frames (overlap)

SPECTRUM_BINS = 80       # number of bars to display

SAMPLE_REDUCE = 1        # reduce sampling rate by factor (keep 1 unless memory issue)


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

# Helper utilities

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

def load_wav_file(path):

    """Read WAV file and return (rate, samples as float32 mono)."""

    rate, data = wavfile.read(path)

    # normalize and convert to float32 in range [-1,1]

    if data.dtype == np.int16:

        data = data.astype(np.float32) / 32768.0

    elif data.dtype == np.int32:

        data = data.astype(np.float32) / 2147483648.0

    elif data.dtype == np.uint8:

        data = (data.astype(np.float32) - 128) / 128.0

    else:

        data = data.astype(np.float32)


    # if stereo, convert to mono by averaging channels

    if data.ndim == 2:

        data = data.mean(axis=1)


    # optionally downsample (by integer factor)

    if SAMPLE_REDUCE > 1:

        data = data[::SAMPLE_REDUCE]

        rate = rate // SAMPLE_REDUCE


    return rate, data


def compute_spectrum(frame, rate, n_bins=SPECTRUM_BINS):

    """

    Compute FFT amplitude spectrum for a frame (1D array).

    Returns frequency centers and amplitudes (log-scaled).

    """

    # apply a window to reduce spectral leakage

    window = np.hanning(len(frame))

    frame_windowed = frame * window

    # FFT

    fft = np.fft.rfft(frame_windowed, n=FRAME_SIZE)

    mags = np.abs(fft)

    # convert to dB scale

    mags_db = 20 * np.log10(mags + 1e-6)

    freqs = np.fft.rfftfreq(FRAME_SIZE, d=1.0 / rate)

    # reduce to n_bins by averaging contiguous bands (log spaced bins could be better)

    # we'll use linear bins for simplicity

    bins = np.array_split(np.arange(len(freqs)), n_bins)

    bfreqs = []

    bampl = []

    for b in bins:

        if len(b) == 0:

            bfreqs.append(0)

            bampl.append(-120.0)

            continue

        bfreqs.append(freqs[b].mean())

        bampl.append(mags_db[b].mean())

    # normalize amplitudes to 0..1 for plotting bars

    a = np.array(bampl)

    a = (a - a.min()) / (np.maximum(a.max() - a.min(), 1e-6))

    return np.array(bfreqs), a


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

# Main App

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

class SpectrumVisualizerApp:

    def __init__(self, root):

        self.root = root

        self.root.title("Audio Frequency Spectrum Visualizer")

        self.root.geometry("1000x650")


        # Top controls

        ctrl = ttk.Frame(root)

        ctrl.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)


        self.path_var = tk.StringVar()

        ttk.Label(ctrl, text="Audio File:").pack(side=tk.LEFT)

        self.path_entry = ttk.Entry(ctrl, textvariable=self.path_var, width=60)

        self.path_entry.pack(side=tk.LEFT, padx=6)

        ttk.Button(ctrl, text="Browse", command=self.browse_file).pack(side=tk.LEFT, padx=4)

        ttk.Button(ctrl, text="Load", command=self.load_file).pack(side=tk.LEFT, padx=4)

        ttk.Button(ctrl, text="Start", command=self.start).pack(side=tk.LEFT, padx=4)

        ttk.Button(ctrl, text="Pause/Resume", command=self.toggle_pause).pack(side=tk.LEFT, padx=4)

        ttk.Button(ctrl, text="Stop", command=self.stop).pack(side=tk.LEFT, padx=4)


        # Status

        self.status_var = tk.StringVar(value="No file loaded")

        ttk.Label(root, textvariable=self.status_var).pack(side=tk.TOP, anchor="w", padx=8)


        # Matplotlib figure with two subplots (waveform + spectrum)

        self.fig, (self.ax_wave, self.ax_spec) = plt.subplots(2, 1, figsize=(9, 6), gridspec_kw={'height_ratios':[1,1]})

        plt.tight_layout(pad=3.0)


        self.canvas = FigureCanvasTkAgg(self.fig, master=root)

        self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)


        # controls / internal state

        self.sr = None

        self.samples = None

        self.num_frames = 0

        self.current_frame_idx = 0

        self.paused = True

        self.ani = None


        # waveform plot placeholders

        self.wave_x = None

        self.wave_plot = None

        # spectrum bars

        self.bar_container = None

        self.freq_centers = None


        # initialize plots

        self.setup_empty_plots()


    def setup_empty_plots(self):

        self.ax_wave.clear()

        self.ax_wave.set_title("Waveform (time domain)")

        self.ax_wave.set_xlabel("Time (s)")

        self.ax_wave.set_ylabel("Amplitude")

        self.ax_spec.clear()

        self.ax_spec.set_title("Frequency Spectrum (animated)")

        self.ax_spec.set_xlabel("Frequency (Hz)")

        self.ax_spec.set_ylabel("Normalized amplitude")

        self.canvas.draw_idle()


    def browse_file(self):

        p = filedialog.askopenfilename(filetypes=[("WAV files", "*.wav"), ("All files", "*.*")])

        if p:

            self.path_var.set(p)


    def load_file(self):

        path = self.path_var.get().strip()

        if not path or not os.path.exists(path):

            messagebox.showerror("Error", "Please choose a valid WAV file.")

            return

        try:

            self.sr, self.samples = load_wav_file(path)

        except Exception as e:

            messagebox.showerror("Error", f"Failed to read WAV: {e}")

            return


        # compute frames count

        self.num_frames = max(1, (len(self.samples) - FRAME_SIZE) // HOP_SIZE + 1)

        self.current_frame_idx = 0

        self.status_var.set(f"Loaded: {os.path.basename(path)} | SR={self.sr} Hz | Samples={len(self.samples)} | Frames={self.num_frames}")


        # draw full waveform

        t = np.arange(len(self.samples)) / float(self.sr)

        self.ax_wave.clear()

        self.ax_wave.plot(t, self.samples, color='gray', linewidth=0.5)

        self.ax_wave.set_xlim(t.min(), t.max())

        self.ax_wave.set_ylim(-1.0, 1.0)

        self.ax_wave.set_title("Waveform (time domain)")

        self.ax_wave.set_xlabel("Time (s)")

        self.ax_wave.set_ylabel("Amplitude")


        # prepare spectrum bar placeholders using first frame

        frame0 = self.samples[:FRAME_SIZE]

        freqs, amps = compute_spectrum(frame0, self.sr, n_bins=SPECTRUM_BINS)

        self.freq_centers = freqs

        x = np.arange(len(amps))

        self.ax_spec.clear()

        self.bar_container = self.ax_spec.bar(x, amps, align='center', alpha=0.8)

        self.ax_spec.set_xticks(x[::max(1,len(x)//10)])

        # show frequency labels at a few ticks

        tick_idx = np.linspace(0, len(freqs)-1, min(10, len(freqs))).astype(int)

        tick_labels = [f"{int(freqs[i])}Hz" for i in tick_idx]

        self.ax_spec.set_xticks(tick_idx)

        self.ax_spec.set_xticklabels(tick_labels, rotation=45)

        self.ax_spec.set_ylim(0, 1.02)

        self.ax_spec.set_title("Frequency Spectrum (animated)")


        # vertical line on waveform to show current frame

        self.wave_marker = self.ax_wave.axvline(0, color='red', linewidth=1.0)


        self.canvas.draw_idle()


    def start(self):

        if self.samples is None:

            messagebox.showwarning("No file", "Load a WAV file first.")

            return

        if self.ani:

            # reset animation

            self.ani.event_source.stop()

            self.ani = None

        self.paused = False

        # Create animation that updates every ~30 ms

        self.ani = FuncAnimation(self.fig, self.update_frame, interval=30, blit=False)

        self.canvas.draw_idle()

        self.status_var.set("Playing visualization... (simulation)")


    def toggle_pause(self):

        if self.ani is None:

            return

        if self.paused:

            self.paused = False

            self.status_var.set("Resumed")

        else:

            self.paused = True

            self.status_var.set("Paused")


    def stop(self):

        if self.ani:

            self.ani.event_source.stop()

            self.ani = None

        self.current_frame_idx = 0

        self.paused = True

        self.status_var.set("Stopped")

        # reset waveform marker

        if hasattr(self, 'wave_marker') and self.wave_marker:

            self.wave_marker.set_xdata(0)

        self.canvas.draw_idle()


    def update_frame(self, *args):

        if self.samples is None or self.paused:

            return


        # compute frame start/end

        start = int(self.current_frame_idx * HOP_SIZE)

        end = start + FRAME_SIZE

        if end > len(self.samples):

            # loop to start for continuous visual demo

            self.current_frame_idx = 0

            start = 0

            end = FRAME_SIZE


        frame = self.samples[start:end]

        # update waveform marker

        t_pos = (start + FRAME_SIZE/2) / float(self.sr)

        self.wave_marker.set_xdata(t_pos)


        # compute spectrum

        freqs, amps = compute_spectrum(frame, self.sr, n_bins=SPECTRUM_BINS)


        # update bars

        for rect, h in zip(self.bar_container, amps):

            rect.set_height(h)


        # optional: change bar colors based on amplitude

        for rect, h in zip(self.bar_container, amps):

            rect.set_color(plt.cm.viridis(h))


        self.current_frame_idx += 1

        if self.current_frame_idx >= self.num_frames:

            self.current_frame_idx = 0  # loop


        self.canvas.draw_idle()


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

# Run the app

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

def main():

    root = tk.Tk()

    app = SpectrumVisualizerApp(root)

    root.mainloop()


if __name__ == "__main__":

    main()


No comments: