diff --git a/sync_audio_test.py b/sync_audio_test.py index 8519adc..87dc77a 100644 --- a/sync_audio_test.py +++ b/sync_audio_test.py @@ -59,6 +59,25 @@ def decimate_for_plot(arr: np.ndarray, max_pts: int = 120_000) -> tuple[np.ndarr stride = int(math.ceil(len(arr) / max_pts)) return arr[::stride], stride +def decimate_with_peak(arr: np.ndarray, peak_idx: int, max_pts: int = 200_000) -> tuple[np.ndarray, np.ndarray]: + """Decimate array for plotting using max-pooling to preserve all peaks.""" + n = len(arr) + if n <= max_pts: + return arr, np.arange(n, dtype=np.float64) + stride = int(math.ceil(n / max_pts)) + abs_arr = np.abs(arr) + n_blocks = (n + stride - 1) // stride + out = np.empty(n_blocks, dtype=arr.dtype) + idx_out = np.empty(n_blocks, dtype=np.float64) + for i in range(n_blocks): + start = i * stride + end = min(start + stride, n) + block_abs = abs_arr[start:end] + local_peak = int(np.argmax(block_abs)) + out[i] = arr[start + local_peak] + idx_out[i] = float(start + local_peak) + return out, idx_out + def find_wavs_from_config(cfg: dict) -> tuple[str, str, int, float, float]: audio_cfg = cfg.get("audio", {}) @@ -128,13 +147,14 @@ class SyncWindow(QtWidgets.QMainWindow): # xcorr display — corr is already fftshifted N = len(self.corr) # lags in seconds for display - # raw corr was shifted: center at N//2, range [- (len(b)-1) .. len(a)-1] - # for display we map indices to lag total = len(self.a) + len(self.b) - 1 n_pow2 = N # shifted lags: -N//2 .. N//2-1 self.corr_lags = np.arange(N) - N//2 self.corr_lags_sec = self.corr_lags / sr + # find peak index in raw (unshifted) for decimation + peak_raw_idx = int(np.argmax(np.abs(self.corr))) # this is fftshifted, peak near center + # for decimation we need the index in the shifted array central = QtWidgets.QWidget() self.setCentralWidget(central) @@ -169,9 +189,10 @@ class SyncWindow(QtWidgets.QMainWindow): self.p3 = self.gw.addPlot(row=2, col=0, title="Cross-correlation (GCC-PHAT)" if method=="phat" else "Cross-correlation") self.p3.showGrid(x=True, y=True) self.p3.setLabel("bottom", "Lag", "s") - # decimate corr for perf if huge - corr_dec, corr_stride = decimate_for_plot(self.corr, max_pts=200_000) - corr_lags_dec = self.corr_lags_sec[::corr_stride] if corr_stride>1 else self.corr_lags_sec + # decimate corr with peak awareness + peak_in_shifted = int(np.argmax(np.abs(self.corr))) + corr_dec, corr_idx = decimate_with_peak(self.corr, peak_in_shifted, max_pts=200_000) + corr_lags_dec = self.corr_lags_sec[corr_idx.astype(int)] self.curve_corr = self.p3.plot(corr_lags_dec, np.abs(corr_dec), pen=pg.mkPen("#ffd166", width=1)) self.peak_line = pg.InfiniteLine(pos=self.lag_sec, angle=90, pen=pg.mkPen("#06d6a0", width=2, style=QtCore.Qt.PenStyle.DashLine)) self.p3.addItem(self.peak_line) @@ -213,6 +234,9 @@ class SyncWindow(QtWidgets.QMainWindow): btn_swap = QtWidgets.QPushButton("Swap A/B") ctr.addWidget(btn_swap) + btn_export = QtWidgets.QPushButton("Export data") + ctr.addWidget(btn_export) + # volume + playback ctr2 = QtWidgets.QHBoxLayout() lay.addLayout(ctr2) @@ -249,6 +273,7 @@ class SyncWindow(QtWidgets.QMainWindow): self.vol.valueChanged.connect(lambda v: self.vol_label.setText(f"{v}%")) btn_auto.clicked.connect(self.on_auto) btn_swap.clicked.connect(self.on_swap) + btn_export.clicked.connect(self.on_export) self.btn_play_a.clicked.connect(lambda: self.play("a")) self.btn_play_b.clicked.connect(lambda: self.play("b")) self.btn_play_mix.clicked.connect(lambda: self.play("mix")) @@ -321,6 +346,53 @@ class SyncWindow(QtWidgets.QMainWindow): self.corr = corr self._set_lag(lag/self.sr, "swap") + def on_export(self): + import json + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export sync data", "sync_data.json", "JSON (*.json)") + if not path: + return + # export decimated correlation (full res is too large) + peak_in_shifted = int(np.argmax(np.abs(self.corr))) + corr_dec, corr_idx = decimate_with_peak(self.corr, peak_in_shifted, max_pts=200_000) + corr_lags_sec = self.corr_lags_sec[corr_idx.astype(int)] + export = { + "file_a": self.a_path, + "file_b": self.b_path, + "sr": self.sr, + "method": self.method, + "device": self.device_pref, + "lag_samples": self.lag_samples, + "lag_sec": self.lag_sec, + "peak_value": float(self.peak), + "a_samples": len(self.a_orig), + "a_duration_sec": len(self.a_orig) / self.sr, + "b_samples": len(self.b_orig), + "b_duration_sec": len(self.b_orig) / self.sr, + "corr_peak_index": peak_in_shifted, + "corr_length": len(self.corr), + # decimated correlation for plotting (not full 4M points) + "corr_lags_sec": corr_lags_sec.tolist(), + "corr_magnitude": np.abs(corr_dec).tolist(), + # waveform stats (full res waveforms too large for JSON) + "a_stats": { + "mean": float(self.a_orig.mean()), + "std": float(self.a_orig.std()), + "min": float(self.a_orig.min()), + "max": float(self.a_orig.max()), + }, + "b_stats": { + "mean": float(self.b_orig.mean()), + "std": float(self.b_orig.std()), + "min": float(self.b_orig.min()), + "max": float(self.b_orig.max()), + }, + } + with open(path, "w") as f: + json.dump(export, f, indent=2) + print(f"exported to {path}") + self.setWindowTitle(f"movie_sync — exported to {os.path.basename(path)}") + def play(self, which: str): if not HAS_SD: return