#!/usr/bin/env python3 import argparse, threading, os import numpy as np import sounddevice as sd import matplotlib # GUI-Backend wählen if os.environ.get("DISPLAY"): matplotlib.use("TkAgg", force=True) else: matplotlib.use("Agg", force=True) import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation def ema_update(prev, new, alpha): return new if prev is None else (alpha * new + (1 - alpha) * prev) def main(): ap = argparse.ArgumentParser(description="Live Mic-Scope (glatt, autoscale, Clip-Anzeige)") ap.add_argument("-r", "--samplerate", type=int, default=48_000) ap.add_argument("-c", "--channels", type=int, default=1) ap.add_argument("-b", "--blocksize", type=int, default=1024) ap.add_argument("-w", "--window", type=float, default=0.8, help="Anzeigefenster in Sekunden") ap.add_argument("--indev", type=int, default=None, help="Input-Geräteindex") ap.add_argument("--gain", type=float, default=1.0, help="Anzeige-Gain (nur Darstellung)") ap.add_argument("--smooth-ms", type=float, default=5.0, help="Glättung des Waveforms (ms)") ap.add_argument("--level-ema", type=float, default=0.30, help="Level-EMA Zeitkonstante in s") ap.add_argument("--autoscale", action="store_true", help="Automatische Y-Skalierung aktivieren") ap.add_argument("--ymax", type=float, default=1.05, help="Fixe Y-Grenze, wenn Autoscale aus") args = ap.parse_args() fs = args.samplerate nwin = int(args.window * fs) buf = np.zeros(nwin, dtype=np.float32) lock = threading.Lock() # State level_dbfs = None peak_hold = 0.0 clip_pct = 0.0 disp_ymax = args.ymax if not args.autoscale else None # wird dynamisch gesetzt # Glättungskern (nur für Darstellung) k = max(1, int(args.smooth_ms * fs / 1000)) kernel = np.ones(k, dtype=np.float32) / k if k > 1 else None # Level-EMA Alpha aus Zeitkonstante lvl_alpha = 1.0 - np.exp(-args.blocksize / (fs * max(1e-3, args.level_ema))) def callback(indata, frames, time_info, status): nonlocal buf, level_dbfs, peak_hold, clip_pct if status: print(status) # nur erster Kanal x = indata[:, 0].copy() # Clip-Detektion (echtes Input-Clipping => abs(x) ~ 1.0) c = np.mean(np.abs(x) >= 0.999) clip_pct = 0.9 * clip_pct + 0.1 * c # leicht glätten # RMS -> dBFS (vor Gain, um echte Nähe zu 0 dBFS zu sehen) rms = np.sqrt(np.mean(np.clip(x, -1.0, 1.0) ** 2) + 1e-20) db = 20 * np.log10(rms) level_dbfs = ema_update(level_dbfs, db, lvl_alpha) # Ringpuffer with lock: if len(x) >= len(buf): buf[:] = x[-len(buf):] else: buf[:-len(x)] = buf[len(x):] buf[-len(x):] = x # Peak-Hold (für Textanzeige) peak = float(np.max(np.abs(x))) peak_hold = max(peak, peak_hold * 0.95) # langsamer Abkling stream_kwargs = dict(samplerate=fs, channels=args.channels, dtype="float32", blocksize=args.blocksize) if args.indev is not None: stream_kwargs["device"] = (args.indev, None) # Plot-Setup fig, ax = plt.subplots(figsize=(10, 4)) t = np.linspace(-args.window, 0, nwin) (line,) = ax.plot(t, np.zeros_like(t), lw=1) ax.set_title("Mikrofon – Live Waveform (glättet & autoscaled)") ax.set_xlabel("Zeit [s]"); ax.set_ylabel("Amplitude") ax.set_ylim(-args.ymax, args.ymax) ax.grid(True, alpha=0.3) txt = ax.text(0.01, 0.92, "", ha="left", va="center", transform=ax.transAxes) clip_txt = ax.text(0.99, 0.92, "", ha="right", va="center", transform=ax.transAxes) # weiche Autoscale-EMA für Y-Limits y_ema = None y_alpha = 0.15 # Höhere Werte => schnellere Anpassung def update(_frame): nonlocal disp_ymax, y_ema with lock: y = buf.copy() # Anzeige-Gain y *= args.gain # Glättung nur für Darstellung if kernel is not None and len(y) >= len(kernel): y = np.convolve(y, kernel, mode="same") # Autoscale (robust): Ziel = 95. Perzentil(|y|) * 1.2 if args.autoscale: target = float(np.percentile(np.abs(y), 95)) * 1.2 target = max(target, 0.2) # minimal sinnvoller Bereich y_ema = ema_update(y_ema, target, y_alpha) disp_ymax = max(0.2, min(1.5 * args.gain, y_ema)) # clamp ax.set_ylim(-disp_ymax, +disp_ymax) line.set_ydata(y) # Info-Text db = level_dbfs if level_dbfs is not None else -np.inf txt.set_text(f"Level: {db:6.1f} dBFS Peak: {peak_hold*args.gain:0.2f} Gain: {args.gain:g}" + (f" Y±{disp_ymax:0.2f}" if args.autoscale else f" Y±{args.ymax:0.2f}")) # Clip-Warnung (rot, wenn >0.5% geclippt) if clip_pct > 0.005: clip_txt.set_text(f"CLIP {clip_pct*100:4.1f}%") clip_txt.set_color("red") else: clip_txt.set_text("") return line, txt, clip_txt with sd.InputStream(callback=callback, **stream_kwargs): ani = FuncAnimation(fig, update, interval=33, blit=False) # ~30 FPS print("Live-Ansicht läuft. Fenster schließen oder Strg+C zum Beenden.") plt.show() if __name__ == "__main__": main()