137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
#!/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()
|