612 lines
27 KiB
Python
612 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
import argparse
|
||
import csv
|
||
from dataclasses import dataclass
|
||
import numpy as np
|
||
import sounddevice as sd
|
||
import matplotlib
|
||
matplotlib.use("TkAgg") # GUI-Ausgabe für interaktives Fenster
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.ticker as mticker
|
||
from matplotlib.widgets import Button, Slider, CheckButtons
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
|
||
# ---------- Audio/Signal-Helfer ----------
|
||
@dataclass
|
||
class Times:
|
||
dac_first_time: float | None = None
|
||
adc_first_time: float | None = None
|
||
|
||
def generate_tone(f_hz: float, dur_s: float, fs: int, volume: float,
|
||
pre_silence: float = 0.20, post_silence: float = 0.40):
|
||
"""Stille + Sinus + Stille (mit 5ms Fade-in/out)."""
|
||
n_pre = int(pre_silence * fs)
|
||
n_tone = int(dur_s * fs)
|
||
n_post = int(post_silence * fs)
|
||
t = np.arange(n_tone) / fs
|
||
tone = np.sin(2 * np.pi * f_hz * t).astype(np.float32)
|
||
fade_n = max(1, int(0.005 * fs))
|
||
w = np.ones_like(tone)
|
||
w[:fade_n] *= np.linspace(0, 1, fade_n, endpoint=False)
|
||
w[-fade_n:] *= np.linspace(1, 0, fade_n, endpoint=False)
|
||
ref = (volume * tone * w).astype(np.float32)
|
||
out = np.concatenate([np.zeros(n_pre, dtype=np.float32), ref, np.zeros(n_post, dtype=np.float32)])
|
||
return out, ref, n_pre
|
||
|
||
def detect_onset_xcorr(signal: np.ndarray, ref: np.ndarray):
|
||
"""Normierte Kreuzkorrelation; liefert Onset-Index und Confidence."""
|
||
x = signal.astype(np.float64)
|
||
r = ref.astype(np.float64)
|
||
M, N = len(r), len(x)
|
||
if N < M + 1:
|
||
return 0, np.array([0.0]), 0.0
|
||
# einfache Vor-Whitening (Hochpass) stabilisiert
|
||
xw = np.concatenate([[x[0]], x[1:] - 0.97 * x[:-1]])
|
||
rw = np.concatenate([[r[0]], r[1:] - 0.97 * r[:-1]])
|
||
corr = np.correlate(xw, rw, mode="valid")
|
||
x2 = xw**2
|
||
cs = np.concatenate([[0.0], np.cumsum(x2)])
|
||
E_x = cs[M:] - cs[:-M]
|
||
E_r = np.sum(rw**2) + 1e-20
|
||
nrm = np.sqrt(E_x * E_r) + 1e-20
|
||
nxc = corr / nrm
|
||
k = int(np.argmax(nxc))
|
||
conf = float(nxc[k])
|
||
return k, nxc, conf
|
||
|
||
# Simple biquad band-pass (RBJ cookbook) and direct-form I filter
|
||
def design_biquad_bandpass(fs: float, f0: float, Q: float) -> tuple[np.ndarray, np.ndarray]:
|
||
w0 = 2.0 * np.pi * (f0 / fs)
|
||
alpha = np.sin(w0) / (2.0 * Q)
|
||
b0 = Q * alpha
|
||
b1 = 0.0
|
||
b2 = -Q * alpha
|
||
a0 = 1.0 + alpha
|
||
a1 = -2.0 * np.cos(w0)
|
||
a2 = 1.0 - alpha
|
||
# Normalize
|
||
b = np.array([b0/a0, b1/a0, b2/a0], dtype=np.float64)
|
||
a = np.array([1.0, a1/a0, a2/a0], dtype=np.float64)
|
||
return b, a
|
||
|
||
def lfilter_biquad(b: np.ndarray, a: np.ndarray, x: np.ndarray) -> np.ndarray:
|
||
y = np.zeros_like(x, dtype=np.float64)
|
||
x1 = x2 = y1 = y2 = 0.0
|
||
b0, b1, b2 = float(b[0]), float(b[1]), float(b[2])
|
||
a1, a2 = float(a[1]), float(a[2])
|
||
for i in range(len(x)):
|
||
xi = float(x[i])
|
||
yi = b0*xi + b1*x1 + b2*x2 - a1*y1 - a2*y2
|
||
y[i] = yi
|
||
x2, x1 = x1, xi
|
||
y2, y1 = y1, yi
|
||
return y.astype(np.float32)
|
||
|
||
def measure_latency_once(freq_hz: float, fs: int, dur_s: float, volume: float,
|
||
indev: int | None, outdev: int | None,
|
||
pre_silence: float = 0.20, post_silence: float = 0.40,
|
||
blocksize: int | None = None, iolatency: float | str | None = None,
|
||
estimator: str = "xcorr", xrun_counter: dict | None = None,
|
||
bandpass: bool = True, rms_info: dict | None = None,
|
||
io_info: dict | None = None):
|
||
"""Spielt einen Ton, nimmt parallel auf, schätzt Latenz in ms, gibt Confidence zurück."""
|
||
play_buf, ref, n_pre = generate_tone(freq_hz, dur_s, fs, volume, pre_silence, post_silence)
|
||
record_buf = []
|
||
written = 0
|
||
times = Times()
|
||
|
||
def cb(indata, outdata, frames, time_info, status):
|
||
nonlocal written, times
|
||
if status:
|
||
# Ausgabe nur informativ; Xruns etc. beeinflussen Latenz
|
||
print(status, flush=True)
|
||
if xrun_counter is not None:
|
||
try:
|
||
xrun_counter["count"] = int(xrun_counter.get("count", 0)) + 1
|
||
except Exception:
|
||
pass
|
||
# Input RMS/clip
|
||
if rms_info is not None:
|
||
try:
|
||
rms = float(np.sqrt(np.mean(np.square(indata.astype(np.float64)))))
|
||
peak = float(np.max(np.abs(indata)))
|
||
rms_db = -120.0 if rms <= 1e-9 else 20.0 * np.log10(rms)
|
||
rms_info["rms_dbfs"] = rms_db
|
||
rms_info["clip"] = bool(peak >= 0.999)
|
||
except Exception:
|
||
pass
|
||
# Report actual frames-per-buffer used by the stream
|
||
if io_info is not None:
|
||
try:
|
||
io_info["blocksize_actual"] = int(frames)
|
||
except Exception:
|
||
pass
|
||
if times.adc_first_time is None:
|
||
times.adc_first_time = time_info.inputBufferAdcTime
|
||
|
||
chunk = play_buf[written:written+frames]
|
||
out = np.zeros((frames,), dtype=np.float32)
|
||
if len(chunk) > 0:
|
||
out[:len(chunk)] = chunk
|
||
|
||
if times.dac_first_time is None and np.any(out != 0.0):
|
||
first_nz = int(np.argmax(out != 0.0))
|
||
times.dac_first_time = time_info.outputBufferDacTime + first_nz / fs
|
||
|
||
outdata[:] = out.reshape(-1, 1)
|
||
record_buf.append(indata.copy().reshape(-1))
|
||
written += frames
|
||
|
||
stream_kwargs = dict(samplerate=fs, dtype="float32", channels=1)
|
||
if indev is not None or outdev is not None:
|
||
stream_kwargs["device"] = (indev, outdev)
|
||
if blocksize is not None:
|
||
stream_kwargs["blocksize"] = int(blocksize)
|
||
if iolatency is not None:
|
||
stream_kwargs["latency"] = iolatency
|
||
|
||
with sd.Stream(callback=cb, **stream_kwargs):
|
||
sd.sleep(int(1000 * (len(play_buf) / fs)))
|
||
sd.sleep(200)
|
||
|
||
if not record_buf:
|
||
return np.nan, 0.0
|
||
|
||
rec = np.concatenate(record_buf).astype(np.float32)
|
||
# Optional band-pass around the test tone to increase SNR
|
||
if bandpass:
|
||
try:
|
||
b, a = design_biquad_bandpass(fs=float(fs), f0=float(freq_hz), Q=8.0)
|
||
rec = lfilter_biquad(b, a, rec)
|
||
except Exception:
|
||
pass
|
||
onset_idx, _, conf = detect_onset_xcorr(rec, ref)
|
||
|
||
if estimator == "timeinfo":
|
||
if times.adc_first_time is None or times.dac_first_time is None:
|
||
return np.nan, conf
|
||
adc_detect_time = times.adc_first_time + onset_idx / fs
|
||
latency_ms = (adc_detect_time - times.dac_first_time) * 1000.0
|
||
return float(latency_ms), conf
|
||
else: # "xcorr" (default)
|
||
latency_samples = max(0, int(onset_idx) - int(n_pre))
|
||
latency_ms = (latency_samples / fs) * 1000.0
|
||
return float(latency_ms), conf
|
||
|
||
# ---------- 440-Hz-Runner & Einzel-Balken-Plot ----------
|
||
def run_440(repeats, fs, dur, vol, indev, outdev, conf_min):
|
||
f = 440.0
|
||
latencies: list[float] = []
|
||
confidences: list[float] = []
|
||
for i in range(repeats):
|
||
lat_ms, conf = measure_latency_once(f, fs, dur, vol, indev, outdev)
|
||
latencies.append(lat_ms)
|
||
confidences.append(conf)
|
||
print(f"Try {i+1}/{repeats}: f=440 Hz -> latency={lat_ms:.2f} ms conf={conf:.3f}")
|
||
bad = sum((np.isnan(v) or c < conf_min) for v, c in zip(latencies, confidences))
|
||
if bad > 0:
|
||
print(f"Warnung: {bad} Messungen mit niedriger Confidence (< {conf_min}) oder NaN.")
|
||
return latencies, confidences
|
||
|
||
def plot_single_bar(latencies: list[float]):
|
||
data = np.array(latencies, dtype=float)
|
||
mean = float(np.nanmean(data)) if data.size else np.nan
|
||
std = float(np.nanstd(data, ddof=1)) if data.size > 1 else 0.0
|
||
vmin = float(np.nanmin(data)) if data.size else 0.0
|
||
vmax = float(np.nanmax(data)) if data.size else 0.0
|
||
|
||
fig, ax = plt.subplots(figsize=(5, 6))
|
||
ax.set_title("Latenz bei 440 Hz")
|
||
# Einzelner Balken bei x=0
|
||
ax.bar([0], [mean], color="#4C78A8", width=0.6, label="Mittelwert")
|
||
# Fehlerbalken = Standardabweichung
|
||
ax.errorbar([0], [mean], yerr=[[std], [std]], fmt="none", ecolor="#333333", capsize=6, label="Std")
|
||
# Spannweite min..max als vertikale Linie
|
||
ax.vlines(0, vmin, vmax, colors="#E45756", linewidth=3, label="Min–Max")
|
||
ax.set_xticks([0])
|
||
ax.set_xticklabels(["440 Hz"])
|
||
# y-Achse startet immer bei 0
|
||
ymax = max(1.0, (vmax if np.isfinite(vmax) else 0.0))
|
||
ax.set_ylim(0.0, ymax * 1.1)
|
||
ax.set_ylabel("Latenz [ms]")
|
||
ax.grid(True, axis="y", alpha=0.3)
|
||
ax.legend(loc="best")
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
def run_gui(fs: int, dur: float, vol: float, indev: int | None, outdev: int | None,
|
||
conf_min: float, blocksize: int | None = None, iolatency: float | str | None = None,
|
||
estimator: str = "xcorr", pre_silence: float = 0.20, post_silence: float = 0.40,
|
||
bandpass: bool = True):
|
||
latencies: list[float] = []
|
||
confidences: list[float] = []
|
||
|
||
fig = plt.figure(figsize=(11, 6))
|
||
# Leave space at bottom for controls; scatter on left, terminal on right
|
||
plt.tight_layout(rect=[0, 0.16, 1, 1])
|
||
|
||
# Scatterplot of latency vs sample index (left)
|
||
ax_sc = fig.add_axes([0.08, 0.22, 0.62, 0.72])
|
||
ax_sc.set_title("Latency over samples", loc="left")
|
||
ax_sc.set_xlabel("sample index")
|
||
ax_sc.set_ylabel("latency [ms]")
|
||
ax_sc.grid(True, axis="both", alpha=0.25)
|
||
# Zero reference line
|
||
zero_line = ax_sc.axhline(0.0, color="#999999", linewidth=1, alpha=0.6, zorder=0)
|
||
# Two series (legacy, cleared each update) and one confidence-graded scatter
|
||
sc_valid, = ax_sc.plot([], [], 'o', color="#4C78A8", markersize=6, label="valid")
|
||
sc_low, = ax_sc.plot([], [], 'o', markerfacecolor='none', markeredgecolor="#E45756", markersize=6, label="low/invalid")
|
||
sc_conf = ax_sc.scatter([], [], c=[], s=24, cmap='viridis_r', vmin=0.0, vmax=1.0, edgecolors='none', alpha=0.9)
|
||
# Legend cleanup: show only rolling mean and last sample
|
||
leg = ax_sc.legend([ ], [ ], loc="upper right")
|
||
SCATTER_WINDOW = [50] # fixed default: number of last points to display
|
||
# Rolling mean and std band (initialized empty)
|
||
line_mean, = ax_sc.plot([], [], '-', color="#1f77b4", linewidth=1.5, alpha=0.9, label="rolling mean")
|
||
band_poly = [None]
|
||
# Latest sample highlight
|
||
sc_last, = ax_sc.plot([], [], 'o', color="#2ca02c", markersize=7, label="last")
|
||
ann_last = ax_sc.text(0, 0, "", va="bottom", ha="left", fontsize=8, color="#2ca02c")
|
||
# Add colorbar for confidence
|
||
try:
|
||
cbar = fig.colorbar(sc_conf, ax=ax_sc, fraction=0.046, pad=0.04)
|
||
cbar.set_label('confidence')
|
||
except Exception:
|
||
pass
|
||
|
||
# Terminal-style readout panel (right)
|
||
ax_log = fig.add_axes([0.73, 0.30, 0.23, 0.60])
|
||
ax_log.set_title("Measurements", loc="center", fontsize=9)
|
||
ax_log.axis("off")
|
||
log_text = ax_log.text(0.0, 1.0, "", va="top", ha="left", family="monospace", fontsize=8)
|
||
LOG_WINDOW = 10 # show last 10 lines; start scrolling after 10
|
||
|
||
# Stats panel (immediately below terminal)
|
||
ax_stats = fig.add_axes([0.73, 0.32, 0.23, 0.02])
|
||
ax_stats.axis("off")
|
||
stats_text = ax_stats.text(0.0, 1.0, "", va="top", ha="left", family="monospace", fontsize=8)
|
||
|
||
# Hardware/Status panel (directly below stats)
|
||
ax_hw = fig.add_axes([0.73, 0.28, 0.23, 0.02])
|
||
ax_hw.axis("off")
|
||
hw_text = ax_hw.text(0.0, 1.0, "", va="top", ha="left", family="monospace", fontsize=8)
|
||
|
||
running = threading.Event()
|
||
latest_changed = threading.Event()
|
||
lock = threading.Lock()
|
||
latest_conf = {"value": float("nan")}
|
||
|
||
current_conf_min = [float(conf_min)]
|
||
include_low = [False]
|
||
# status/xrun counter shared with stream callback
|
||
xrun_counter = {"count": 0}
|
||
# input RMS meter shared
|
||
rms_info = {"rms_dbfs": float('nan'), "clip": False}
|
||
# runtime I/O info
|
||
io_info = {"blocksize_actual": None}
|
||
|
||
# Resolve device names for display
|
||
try:
|
||
dev_in_name = sd.query_devices(indev)["name"] if indev is not None else sd.query_devices(sd.default.device[0])["name"]
|
||
except Exception:
|
||
dev_in_name = str(indev)
|
||
try:
|
||
dev_out_name = sd.query_devices(outdev)["name"] if outdev is not None else sd.query_devices(sd.default.device[1])["name"]
|
||
except Exception:
|
||
dev_out_name = str(outdev)
|
||
|
||
def compute_stats():
|
||
data = np.array(latencies, dtype=float)
|
||
conf = np.array(confidences, dtype=float)
|
||
if data.size == 0:
|
||
return float('nan'), 0.0, 0.0, 0.0, 0
|
||
# When include_low is ON, include all finite samples (even negative latencies)
|
||
if include_low[0]:
|
||
mask = np.isfinite(data)
|
||
else:
|
||
mask = np.isfinite(data) & (data >= 0.0)
|
||
if conf.size == data.size:
|
||
mask &= np.isfinite(conf) & (conf >= current_conf_min[0])
|
||
valid = data[mask]
|
||
if valid.size == 0:
|
||
return float('nan'), 0.0, 0.0, 0.0, 0
|
||
mean = float(np.nanmean(valid))
|
||
std = float(np.nanstd(valid, ddof=1)) if valid.size > 1 else 0.0
|
||
vmin = float(np.nanmin(valid))
|
||
vmax = float(np.nanmax(valid))
|
||
return mean, std, vmin, vmax, int(valid.size)
|
||
|
||
def compute_stats_all():
|
||
data = np.array(latencies, dtype=float)
|
||
if data.size == 0:
|
||
return float('nan'), 0
|
||
# 'All' means all finite samples, including negatives
|
||
mask = np.isfinite(data)
|
||
allv = data[mask]
|
||
if allv.size == 0:
|
||
return float('nan'), 0
|
||
return float(np.nanmean(allv)), int(allv.size)
|
||
|
||
# (removed old remove_errorbar helper; no longer needed)
|
||
|
||
def update_plot():
|
||
with lock:
|
||
# Update scatterplot with last N points
|
||
n = len(latencies)
|
||
if n > 0:
|
||
start = max(0, n - SCATTER_WINDOW[0])
|
||
idx = np.arange(start, n)
|
||
y = np.array(latencies[start:n], dtype=float)
|
||
c = np.array(confidences[start:n], dtype=float)
|
||
finite = np.isfinite(y)
|
||
thr = current_conf_min[0]
|
||
is_valid = finite & (y >= 0.0) & np.isfinite(c) & (c >= thr)
|
||
is_low = finite & ~is_valid
|
||
y_plot = y.copy()
|
||
y_plot[~finite] = np.nan
|
||
# Visual floor epsilon to avoid points collapsing exactly at 0
|
||
eps = 0.1 # ms
|
||
y_plot = np.maximum(y_plot, eps)
|
||
# Build display mask respecting include_low and conf_min
|
||
if include_low[0]:
|
||
disp_mask = (np.isfinite(y_plot))
|
||
else:
|
||
disp_mask = is_valid
|
||
# Update confidence-graded scatter
|
||
x_disp = idx[disp_mask]
|
||
y_disp = y_plot[disp_mask]
|
||
c_disp = c[disp_mask]
|
||
if c_disp.size > 0:
|
||
offs = np.column_stack([x_disp, y_disp])
|
||
sc_conf.set_offsets(offs)
|
||
sc_conf.set_array(c_disp.astype(float))
|
||
# Marker size scaled by confidence
|
||
sizes = 16.0 + 36.0 * np.clip(c_disp.astype(float), 0.0, 1.0)
|
||
sc_conf.set_sizes(sizes)
|
||
else:
|
||
sc_conf.set_offsets(np.empty((0, 2)))
|
||
sc_conf.set_array(np.array([], dtype=float))
|
||
sc_conf.set_sizes(np.array([], dtype=float))
|
||
# Clear legacy series to avoid double plotting
|
||
sc_valid.set_data([], [])
|
||
sc_low.set_data([], [])
|
||
# Y-axis starts at 0 with small padding above max
|
||
# Guard all-NaN window: if no finite data, show default axes and clear rolling overlays
|
||
if not np.any(np.isfinite(y_plot)):
|
||
ax_sc.set_ylim(0.0, 1.0)
|
||
ax_sc.set_xlim(max(0, n - SCATTER_WINDOW[0]), max(SCATTER_WINDOW[0], n))
|
||
line_mean.set_data([], [])
|
||
if band_poly[0] is not None:
|
||
try:
|
||
band_poly[0].remove()
|
||
except Exception:
|
||
pass
|
||
band_poly[0] = None
|
||
sc_last.set_data([], [])
|
||
ann_last.set_text("")
|
||
else:
|
||
y_max = float(np.nanmax(y_plot))
|
||
y_low = 0.0
|
||
y_high = max(1.0, y_max)
|
||
pad = 0.05 * (y_high - y_low)
|
||
ax_sc.set_ylim(y_low, y_high + pad)
|
||
# Use nice ticks and a readable formatter
|
||
ax_sc.yaxis.set_major_locator(mticker.MaxNLocator(nbins=6))
|
||
ax_sc.yaxis.set_major_formatter(mticker.FormatStrFormatter('%.1f'))
|
||
ax_sc.set_xlim(max(0, n - SCATTER_WINDOW[0]), max(SCATTER_WINDOW[0], n))
|
||
|
||
# Rolling mean/std band on displayed window (only if some finite data)
|
||
if np.any(np.isfinite(y_plot)):
|
||
win = max(3, min(50, int(max(10, SCATTER_WINDOW[0] * 0.05))))
|
||
y_roll = y_plot.copy()
|
||
m = np.full_like(y_roll, np.nan, dtype=float)
|
||
s = np.full_like(y_roll, np.nan, dtype=float)
|
||
for k in range(len(y_roll)):
|
||
a = max(0, k - win + 1)
|
||
seg = y_roll[a:k+1]
|
||
seg = seg[np.isfinite(seg)]
|
||
if seg.size >= 2:
|
||
m[k] = float(np.nanmean(seg))
|
||
s[k] = float(np.nanstd(seg, ddof=1))
|
||
elif seg.size == 1:
|
||
m[k] = float(seg[0])
|
||
s[k] = 0.0
|
||
line_mean.set_data(idx, m)
|
||
if band_poly[0] is not None:
|
||
try:
|
||
band_poly[0].remove()
|
||
except Exception:
|
||
pass
|
||
upper = m + s
|
||
lower = np.maximum(0.0, m - s)
|
||
band_poly[0] = ax_sc.fill_between(idx, lower, upper, color="#1f77b4", alpha=0.15, linewidth=0)
|
||
|
||
# Latest sample highlight
|
||
last_x = idx[-1]
|
||
last_y = y_plot[-1] if np.isfinite(y_plot[-1]) else np.nan
|
||
if np.isfinite(last_y):
|
||
sc_last.set_data([last_x], [last_y])
|
||
ann_last.set_position((last_x, last_y))
|
||
ann_last.set_text(f"{last_y:.2f} ms")
|
||
else:
|
||
sc_last.set_data([], [])
|
||
ann_last.set_text("")
|
||
|
||
# Update rolling terminal (right)
|
||
lines = []
|
||
thr = current_conf_min[0]
|
||
for i, (lat, conf) in enumerate(zip(latencies, confidences)):
|
||
lat_ok = np.isfinite(lat)
|
||
conf_ok = np.isfinite(conf) and (conf >= thr)
|
||
flag = "OK " if (lat_ok and lat >= 0.0 and conf_ok) else ("LOW" if np.isfinite(conf) else "NA ")
|
||
lat_str = f"{lat:8.2f}" if np.isfinite(lat) else " NaN"
|
||
conf_str = f"{conf:5.3f}" if np.isfinite(conf) else " NaN"
|
||
lines.append(f"{i:04d} | {lat_str} ms | conf={conf_str} | {flag}")
|
||
log_text.set_text("\n".join(lines[-LOG_WINDOW:]))
|
||
|
||
# Update stats panel (respect filters like the scatter)
|
||
data_all = np.array(latencies, dtype=float)
|
||
conf_all = np.array(confidences, dtype=float)
|
||
thr = current_conf_min[0]
|
||
if include_low[0]:
|
||
msk = np.isfinite(data_all)
|
||
else:
|
||
msk = np.isfinite(data_all) & (data_all >= 0.0)
|
||
if conf_all.size == data_all.size:
|
||
msk &= np.isfinite(conf_all) & (conf_all >= thr)
|
||
n_sel = int(np.sum(msk))
|
||
if n_sel >= 1:
|
||
mean_val = float(np.nanmean(data_all[msk]))
|
||
std_val = float(np.nanstd(data_all[msk], ddof=1)) if n_sel >= 2 else 0.0
|
||
stats_text.set_text(f"N={n_sel} mean={mean_val:.2f} ms std={std_val:.2f} ms")
|
||
else:
|
||
stats_text.set_text("N=0 mean=-- std=--")
|
||
|
||
# Update hardware/status panel
|
||
hw_lines = [
|
||
f"fs={fs} Hz, win={SCATTER_WINDOW[0]}",
|
||
f"conf_min={current_conf_min[0]:.2f}, include_low={'on' if include_low[0] else 'off'}",
|
||
f"estimator={estimator}",
|
||
f"indev={indev} ({dev_in_name})",
|
||
f"outdev={outdev} ({dev_out_name})",
|
||
f"blocksize={io_info['blocksize_actual'] if io_info['blocksize_actual'] is not None else (blocksize if blocksize is not None else 'auto')}",
|
||
f"iolatency={iolatency if iolatency is not None else 'default'}",
|
||
f"pre={pre_silence:.2f}s, post={post_silence:.2f}s",
|
||
f"xruns={xrun_counter['count']}",
|
||
f"inRMS={rms_info['rms_dbfs']:.1f} dBFS, clip={'YES' if rms_info['clip'] else 'no'}",
|
||
f"bandpass={'on' if bandpass else 'off'}"
|
||
]
|
||
hw_text.set_text("\n".join(hw_lines))
|
||
fig.canvas.draw_idle()
|
||
|
||
def worker():
|
||
f = 440.0
|
||
while running.is_set():
|
||
lat_ms, conf = measure_latency_once(
|
||
f, fs, dur, vol, indev, outdev,
|
||
pre_silence=pre_silence, post_silence=post_silence,
|
||
blocksize=blocksize, iolatency=iolatency,
|
||
estimator=estimator, xrun_counter=xrun_counter,
|
||
bandpass=bandpass, rms_info=rms_info, io_info=io_info
|
||
)
|
||
with lock:
|
||
# Negative latencies are physically impossible -> mark as invalid (NaN)
|
||
if np.isfinite(lat_ms) and lat_ms < 0.0:
|
||
latencies.append(np.nan)
|
||
else:
|
||
latencies.append(lat_ms)
|
||
confidences.append(conf)
|
||
latest_conf["value"] = conf
|
||
latest_changed.set()
|
||
|
||
def on_start(event):
|
||
if running.is_set():
|
||
return
|
||
running.set()
|
||
if not hasattr(on_start, "thr") or not on_start.thr.is_alive():
|
||
on_start.thr = threading.Thread(target=worker, daemon=True)
|
||
on_start.thr.start()
|
||
|
||
def on_stop(event):
|
||
running.clear()
|
||
|
||
# Slider for confidence threshold
|
||
slider_ax = fig.add_axes([0.10, 0.02, 0.32, 0.05])
|
||
slider = Slider(slider_ax, 'conf_min', 0.0, 1.0, valinit=current_conf_min[0], valstep=0.01)
|
||
|
||
def on_slider(val):
|
||
current_conf_min[0] = float(val)
|
||
update_plot()
|
||
slider.on_changed(on_slider)
|
||
|
||
# Checkbox to include low-confidence samples (placed next to conf_min slider)
|
||
cbox_ax = fig.add_axes([0.45, 0.02, 0.12, 0.05])
|
||
cbox = CheckButtons(cbox_ax, ["include low"], [include_low[0]])
|
||
def on_cbox(label):
|
||
include_low[0] = not include_low[0]
|
||
update_plot()
|
||
cbox.on_clicked(on_cbox)
|
||
|
||
# (removed middle window slider control)
|
||
|
||
start_ax = fig.add_axes([0.54, 0.02, 0.13, 0.06])
|
||
stop_ax = fig.add_axes([0.69, 0.02, 0.13, 0.06])
|
||
reset_ax = fig.add_axes([0.84, 0.02, 0.06, 0.06])
|
||
save_ax = fig.add_axes([0.92, 0.02, 0.06, 0.06])
|
||
btn_start = Button(start_ax, "Start")
|
||
btn_stop = Button(stop_ax, "Stop")
|
||
btn_reset = Button(reset_ax, "Clr")
|
||
btn_save = Button(save_ax, "Save")
|
||
btn_start.on_clicked(on_start)
|
||
btn_stop.on_clicked(on_stop)
|
||
def on_reset(event):
|
||
with lock:
|
||
latencies.clear()
|
||
confidences.clear()
|
||
latest_conf["value"] = float("nan")
|
||
update_plot()
|
||
btn_reset.on_clicked(on_reset)
|
||
|
||
def on_save(event):
|
||
# Save current measurements to CSV with timestamped filename
|
||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
fname = f"latency_{ts}.csv"
|
||
try:
|
||
with open(fname, 'w', encoding='utf-8') as fcsv:
|
||
fcsv.write("# latency_440 export\n")
|
||
fcsv.write(f"# fs,{fs}\n")
|
||
fcsv.write(f"# blocksize,{blocksize}\n")
|
||
fcsv.write(f"# iolatency,{iolatency}\n")
|
||
fcsv.write(f"# estimator,{estimator}\n")
|
||
fcsv.write(f"# pre_silence,{pre_silence}\n")
|
||
fcsv.write(f"# post_silence,{post_silence}\n")
|
||
fcsv.write(f"# bandpass,{bandpass}\n")
|
||
fcsv.write("index,latency_ms,confidence\n")
|
||
with lock:
|
||
for i, (lat, conf) in enumerate(zip(latencies, confidences)):
|
||
lv = '' if not np.isfinite(lat) else f"{lat:.6f}"
|
||
cv = '' if not np.isfinite(conf) else f"{conf:.6f}"
|
||
fcsv.write(f"{i},{lv},{cv}\n")
|
||
except Exception as e:
|
||
print(f"Save failed: {e}")
|
||
btn_save.on_clicked(on_save)
|
||
|
||
# (no old errorbar state to keep)
|
||
|
||
timer = fig.canvas.new_timer(interval=200)
|
||
def on_timer():
|
||
if latest_changed.is_set():
|
||
latest_changed.clear()
|
||
update_plot()
|
||
timer.add_callback(on_timer)
|
||
timer.start()
|
||
|
||
plt.show()
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Akustische Latenzmessung nur für 440 Hz")
|
||
ap.add_argument("--repeats", type=int, default=5, help="Wiederholungen")
|
||
ap.add_argument("-r", "--samplerate", type=int, default=48_000, help="Samplerate")
|
||
ap.add_argument("-t", "--time", type=float, default=0.15, help="Tondauer (s)")
|
||
ap.add_argument("-v", "--volume", type=float, default=0.6, help="Lautstärke 0..1")
|
||
ap.add_argument("--indev", type=int, default=None, help="Input-Geräteindex")
|
||
ap.add_argument("--outdev", type=int, default=None, help="Output-Geräteindex")
|
||
ap.add_argument("--conf-min", type=float, default=0.3, help="Warnschwelle für Confidence")
|
||
ap.add_argument("--blocksize", type=int, default=None, help="Audio blocksize (frames), e.g. 1024/2048")
|
||
ap.add_argument("--iolatency", type=str, default="high", help="Audio I/O latency (seconds or preset: 'low','high')")
|
||
ap.add_argument("--estimator", type=str, choices=["xcorr","timeinfo"], default="xcorr", help="Latency estimator: 'xcorr' (default, robust) or 'timeinfo' (host timestamps)")
|
||
ap.add_argument("--pre-silence", type=float, default=0.30, help="Pre-silence before tone (s)")
|
||
ap.add_argument("--post-silence", type=float, default=0.60, help="Post-silence after tone (s)")
|
||
ap.add_argument("--bandpass", action='store_true', default=True, help="Apply 440 Hz band-pass before correlation")
|
||
ap.add_argument("--no-bandpass", dest='bandpass', action='store_false', help="Disable band-pass prefilter")
|
||
args = ap.parse_args()
|
||
|
||
run_gui(args.samplerate, args.time, args.volume, args.indev, args.outdev,
|
||
args.conf_min, blocksize=args.blocksize, iolatency=args.iolatency,
|
||
estimator=args.estimator, pre_silence=args.pre_silence,
|
||
post_silence=args.post_silence, bandpass=args.bandpass)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|