import numpy as np
import librosa
import librosa.display
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from scipy.ndimage import gaussian_filter1d
import os
import warnings
warnings.filterwarnings("ignore")
class CSDViewer:
def __init__(self, root):
self.root = root
self.root.title("Acoustic Pro Analyzer: CSD & Dynamic Lag")
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
self.root.geometry(f"{int(screen_w*0.8)}x{int(screen_h*0.8)}")
self.data_raw = {'A': None, 'B': None}
self.sr = {'A': None, 'B': None}
self.filenames = {'A': "Impulse A", 'B': "Impulse B"}
self.diff_window = None
self.norm_val = tk.StringVar(value="70.0")
self.decay_range = tk.DoubleVar(value=-40.0)
self.smoothing = tk.StringVar(value="1/12")
ctrl_frame = tk.Frame(root)
ctrl_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)
tk.Button(ctrl_frame, text="Load A (Ref)", command=lambda: self.load_file('A'), width=12).pack(side=tk.LEFT, padx=2)
tk.Button(ctrl_frame, text="Load B", command=lambda: self.load_file('B'), width=12).pack(side=tk.LEFT, padx=2)
ttk.Separator(ctrl_frame, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
tk.Label(ctrl_frame, text="Normalize dB:").pack(side=tk.LEFT)
ttk.Combobox(ctrl_frame, textvariable=self.norm_val, values=["60.0", "70.0", "80.0", "None"], width=7).pack(side=tk.LEFT, padx=5)
tk.Label(ctrl_frame, text="Range dB:").pack(side=tk.LEFT, padx=(10,0))
ttk.Combobox(ctrl_frame, textvariable=self.decay_range, values=[-20.0, -30.0, -40.0, -50.0, -60.0], width=7).pack(side=tk.LEFT, padx=5)
tk.Label(ctrl_frame, text="Smoothing:").pack(side=tk.LEFT, padx=(10,0))
ttk.Combobox(ctrl_frame, textvariable=self.smoothing, values=["None", "1/48", "1/24", "1/12", "1/6", "1/3"], width=7).pack(side=tk.LEFT, padx=5)
self.norm_val.trace_add("write", lambda *a: self.update_all())
self.decay_range.trace_add("write", lambda *a: self.update_all())
self.smoothing.trace_add("write", lambda *a: self.update_all())
tk.Button(ctrl_frame, text="Analyse Time-Lag", command=self.plot_time_diff, bg="#add8e6", font=("Arial", 10, "bold")).pack(side=tk.RIGHT, padx=20)
self.fig, self.axs = plt.subplots(1, 2, figsize=(12, 6), sharey=True, sharex=True)
self.canvas = FigureCanvasTkAgg(self.fig, master=root)
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.toolbar = NavigationToolbar2Tk(self.canvas, root)
def load_file(self, slot):
path = filedialog.askopenfilename(filetypes=[("Audio", "*.wav")])
if path:
try:
self.filenames[slot] = os.path.basename(path)
y, sr = librosa.load(path, sr=None)
y = y / np.max(np.abs(y))
C = librosa.cqt(y, sr=sr, fmin=30, n_bins=120, bins_per_octave=24)
self.data_raw[slot] = np.abs(C)
self.sr[slot] = sr
self.update_all()
except Exception as e: messagebox.showerror("Error", str(e))
def get_processed_db(self, slot):
data = self.data_raw[slot].copy()
s_val = self.smoothing.get()
if s_val != "None":
factors = {"1/48": 0.5, "1/24": 1.0, "1/12": 2.0, "1/6": 4.0, "1/3": 8.0}
data = gaussian_filter1d(data, sigma=factors[s_val], axis=0)
S_db = librosa.amplitude_to_db(data, ref=np.max)
norm = self.norm_val.get()
if norm != "None":
start_level = float(norm)
for j in range(S_db.shape[0]):
S_db[j, :] = S_db[j, :] - S_db[j, 0] + start_level
return S_db
def update_all(self):
self.update_previews()
if self.diff_window and self.diff_window.winfo_exists(): self.plot_time_diff()
def update_previews(self):
norm = self.norm_val.get()
decay = self.decay_range.get()
for i, slot in enumerate(['A', 'B']):
if self.data_raw[slot] is not None:
self.axs[i].clear()
db_data = self.get_processed_db(slot)
vmax = float(norm) if norm != "None" else 0
vmin = vmax + decay
librosa.display.specshow(db_data, x_axis='time', y_axis='cqt_hz', sr=self.sr[slot], ax=self.axs[i], vmin=vmin, vmax=vmax, fmin=30)
self.axs[i].set_title(self.filenames[slot])
self.axs[i].grid(True, which='both', alpha=0.2, color='white')
self.canvas.draw()
def plot_time_diff(self):
if self.data_raw['A'] is None or self.data_raw['B'] is None: return
thresh = self.decay_range.get()
times = {'A': [], 'B': []}
for slot in ['A', 'B']:
spec = self.get_processed_db(slot)
norm_offset = float(self.norm_val.get()) if self.norm_val.get() != "None" else 0
spec -= norm_offset
time_axis = librosa.frames_to_time(np.arange(spec.shape[1]), sr=self.sr[slot], hop_length=512)
for freq_bin in spec:
idx = np.where(freq_bin < thresh)[0]
times[slot].append(time_axis[idx[0]] if len(idx) > 0 else time_axis[-1])
diff = np.array(times['B']) - np.array(times['A'])
freqs = librosa.cqt_frequencies(120, fmin=30, bins_per_octave=24)
if not self.diff_window or not self.diff_window.winfo_exists():
self.diff_window = tk.Toplevel(self.root)
self.d_fig, self.d_ax = plt.subplots(figsize=(9, 8))
self.d_canvas = FigureCanvasTkAgg(self.d_fig, master=self.diff_window)
self.d_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
self.d_toolbar = NavigationToolbar2Tk(self.d_canvas, self.diff_window)
else: self.d_ax.clear()
self.diff_window.title(f"Lag Analysis: Smoothing {self.smoothing.get()}")
colors = ['#ff4d4d' if x < 0 else '#4d4dff' for x in diff]
s_val = self.smoothing.get()
h_factors = {"None": 0.02, "1/48": 0.03, "1/24": 0.04, "1/12": 0.06, "1/6": 0.1, "1/3": 0.2}
bar_h = freqs * h_factors.get(s_val, 0.05)
self.d_ax.barh(freqs, diff, height=bar_h, color=colors, alpha=0.8)
max_l = np.max(np.abs(diff))
limit = max_l * 1.3 if max_l > 0.01 else 0.05
self.d_ax.set_xlim(-limit, limit)
self.d_ax.set_yscale('log')
self.d_ax.set_yticks([31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000])
self.d_ax.get_yaxis().set_major_formatter(plt.ScalarFormatter())
# Labels and Title (FIXED)
self.d_ax.set_title(f"Lag: {self.filenames['B']} vs {self.filenames['A']} @ {thresh}dB\n(Blue = {self.filenames['B']} decays slower)")
self.d_ax.set_xlabel("Time-Lag in Seconds (Right = Blue decays slower)")
self.d_ax.axvline(0, color='black', lw=1.5)
self.d_ax.grid(True, which='both', alpha=0.3)
self.d_canvas.draw()
if __name__ == "__main__":
root = tk.Tk(); app = CSDViewer(root); root.mainloop()