Files
latency_test_suit/mic_scope_smooth.py
2025-10-15 10:38:06 +02:00

137 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"{disp_ymax:0.2f}" if args.autoscale else f"{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()