diff --git a/clap_detection.py b/clap_detection.py index 2c4ec84..9586cf1 100644 --- a/clap_detection.py +++ b/clap_detection.py @@ -19,6 +19,12 @@ class ClapDetectionUnavailable(RuntimeError): pass +# Shared with the GUI's detection-settings dialog (main.py) so both the CLI +# and GUI default to, and can be reset to, the exact same values. +DEFAULT_THRESHOLD = 0.7 +DEFAULT_MIN_INTERVAL = 0.3 + + @dataclass(frozen=True) class ClapEvent: time_sec: float @@ -109,8 +115,8 @@ def _extract_audio_wav(input_path: str, audio_stream_index: Optional[int] = None def detect_claps( input_path: str, start_offset: float = 0.0, - threshold: float = 0.7, - min_interval: float = 0.3, + threshold: float = DEFAULT_THRESHOLD, + min_interval: float = DEFAULT_MIN_INTERVAL, max_results: Optional[int] = None, audio_stream_index: Optional[int] = None, ) -> List[ClapEvent]: diff --git a/main.py b/main.py index c722f7a..a0a8887 100644 --- a/main.py +++ b/main.py @@ -9,13 +9,65 @@ from PyQt6.QtGui import QShortcut, QKeySequence, QAction from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QDoubleSpinBox, QFileDialog, - QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget, + QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget, QDialog, QDialogButtonBox, QFormLayout, ) from player_panel import PlayerPanel, format_timestamp from sync_logic import compute_offset, SyncOffset from ffmpeg_export import export_synced, FfmpegExportError, ExportResult -from clap_detection import detect_claps, ClapDetectionUnavailable, ClapEvent +from clap_detection import ( + detect_claps, ClapDetectionUnavailable, ClapEvent, + DEFAULT_THRESHOLD, DEFAULT_MIN_INTERVAL, +) + + +class ClapSettingsDialog(QDialog): + """Lets the threshold/min-interval knobs from clap_detection.py be + tuned from the GUI instead of only via the CLI's -t/-m flags. Keeps its + own pending values so Cancel doesn't touch the caller's settings.""" + + def __init__(self, threshold: float, min_interval: float, parent=None): + super().__init__(parent) + self.setWindowTitle("Clap Detection Settings") + + self.threshold_spin = QDoubleSpinBox() + self.threshold_spin.setRange(0.1, 2.0) + self.threshold_spin.setSingleStep(0.05) + self.threshold_spin.setDecimals(2) + self.threshold_spin.setValue(threshold) + + self.min_interval_spin = QDoubleSpinBox() + self.min_interval_spin.setRange(0.05, 5.0) + self.min_interval_spin.setSingleStep(0.05) + self.min_interval_spin.setDecimals(2) + self.min_interval_spin.setValue(min_interval) + + form = QFormLayout() + form.addRow("Sensitivity threshold (higher = less sensitive):", self.threshold_spin) + form.addRow("Minimum seconds between claps:", self.min_interval_spin) + + reset_button = QPushButton("Reset to Defaults") + reset_button.clicked.connect(self._reset_to_defaults) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + button_row = QHBoxLayout() + button_row.addWidget(reset_button) + button_row.addStretch(1) + button_row.addWidget(buttons) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addLayout(button_row) + + def _reset_to_defaults(self): + self.threshold_spin.setValue(DEFAULT_THRESHOLD) + self.min_interval_spin.setValue(DEFAULT_MIN_INTERVAL) + + def values(self): + return self.threshold_spin.value(), self.min_interval_spin.value() class ExportWorker(QThread): @@ -45,12 +97,15 @@ class DetectWorker(QThread): finished_ok = pyqtSignal(list) # List[ClapEvent] finished_err = pyqtSignal(str) - def __init__(self, input_path, start_offset=0.0, max_results=None, audio_stream_index=None): + def __init__(self, input_path, start_offset=0.0, max_results=None, audio_stream_index=None, + threshold=DEFAULT_THRESHOLD, min_interval=DEFAULT_MIN_INTERVAL): super().__init__() self.input_path = input_path self.start_offset = start_offset self.max_results = max_results self.audio_stream_index = audio_stream_index + self.threshold = threshold + self.min_interval = min_interval def run(self): try: @@ -59,6 +114,8 @@ class DetectWorker(QThread): start_offset=self.start_offset, max_results=self.max_results, audio_stream_index=self.audio_stream_index, + threshold=self.threshold, + min_interval=self.min_interval, ) self.finished_ok.emit(events) except (ClapDetectionUnavailable, RuntimeError) as e: @@ -80,6 +137,8 @@ class MainWindow(QMainWindow): self._preview_worker: Optional[ExportWorker] = None self._preview_temp_path: Optional[str] = None # currently loaded in preview_panel self._pending_preview_path: Optional[str] = None # being built by _preview_worker + self.clap_threshold: float = DEFAULT_THRESHOLD + self.clap_min_interval: float = DEFAULT_MIN_INTERVAL # Two independent monitors (Kdenlive-style project/clip monitor # tabs): each is a full player with its own timeline and transport, @@ -202,6 +261,21 @@ class MainWindow(QMainWindow): self.detect_next_action.triggered.connect(self._on_detect_next_clicked) tools_menu.addAction(self.detect_next_action) + tools_menu.addSeparator() + + settings_action = QAction("Clap Detection Settings…", self) + settings_action.triggered.connect(self._on_clap_settings_clicked) + tools_menu.addAction(settings_action) + + def _on_clap_settings_clicked(self): + dialog = ClapSettingsDialog(self.clap_threshold, self.clap_min_interval, parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.clap_threshold, self.clap_min_interval = dialog.values() + self.statusBar().showMessage( + f"Clap detection settings updated: threshold={self.clap_threshold:.2f}, " + f"min_interval={self.clap_min_interval:.2f}s", 4000, + ) + def _wire_shortcuts(self): # These act on whichever tab (Source/Preview) is currently active -- # each is a fully independent monitor with its own transport, so the @@ -385,6 +459,7 @@ class MainWindow(QMainWindow): self._detect_worker = DetectWorker( self.input_path, start_offset=start_offset, max_results=max_results, audio_stream_index=self._current_audio_stream_index(), + threshold=self.clap_threshold, min_interval=self.clap_min_interval, ) self._detect_worker.finished_ok.connect(self._on_detect_done) self._detect_worker.finished_err.connect(self._on_detect_failed) diff --git a/scripts/detect_claps.py b/scripts/detect_claps.py index 50dc8a9..5a6b5c8 100755 --- a/scripts/detect_claps.py +++ b/scripts/detect_claps.py @@ -11,7 +11,7 @@ from datetime import timedelta from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from clap_detection import detect_claps, ClapDetectionUnavailable +from clap_detection import detect_claps, ClapDetectionUnavailable, DEFAULT_THRESHOLD, DEFAULT_MIN_INTERVAL def format_timestamp(seconds): @@ -43,10 +43,10 @@ Threshold guide: ) parser.add_argument('input_file', help='Audio or video file') - parser.add_argument('-t', '--threshold', type=float, default=0.7, - help='Detection threshold (0-1, default: 0.7, higher = less sensitive)') - parser.add_argument('-m', '--min-interval', type=float, default=0.3, - help='Minimum seconds between detections (default: 0.3)') + parser.add_argument('-t', '--threshold', type=float, default=DEFAULT_THRESHOLD, + help=f'Detection threshold (0-1, default: {DEFAULT_THRESHOLD}, higher = less sensitive)') + parser.add_argument('-m', '--min-interval', type=float, default=DEFAULT_MIN_INTERVAL, + help=f'Minimum seconds between detections (default: {DEFAULT_MIN_INTERVAL})') parser.add_argument('-s', '--show-strength', action='store_true', help='Show onset strength values') diff --git a/scripts/smoke_test_clap_settings.py b/scripts/smoke_test_clap_settings.py new file mode 100644 index 0000000..cbc5a39 --- /dev/null +++ b/scripts/smoke_test_clap_settings.py @@ -0,0 +1,95 @@ +"""Verifies the Clap Detection Settings dialog: defaults match +clap_detection.py's DEFAULT_THRESHOLD/DEFAULT_MIN_INTERVAL, changing values +and accepting actually updates MainWindow state and gets used by the next +detection run, Cancel leaves state untouched, and Reset to Defaults in the +dialog restores the shared constants (not just whatever MainWindow happened +to start with). +""" +import sys +import time +from pathlib import Path + +from PyQt6.QtWidgets import QApplication, QDialog + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import main as app_main +from clap_detection import DEFAULT_THRESHOLD, DEFAULT_MIN_INTERVAL + +ROOT = Path(__file__).resolve().parent.parent +CLIP = str(ROOT / "test_media" / "sync_test_INSYNC.mp4") + + +def wait_for(predicate, qapp, timeout=20): + deadline = time.time() + timeout + while not predicate() and time.time() < deadline: + qapp.processEvents() + time.sleep(0.05) + return predicate() + + +def main(): + qapp = QApplication(sys.argv) + win = app_main.MainWindow() + win.show() + qapp.processEvents() + + assert win.clap_threshold == DEFAULT_THRESHOLD + assert win.clap_min_interval == DEFAULT_MIN_INTERVAL + print(f"PASS: MainWindow starts with defaults ({DEFAULT_THRESHOLD}, {DEFAULT_MIN_INTERVAL})") + + # -- Dialog opens pre-filled with current settings + dialog = app_main.ClapSettingsDialog(win.clap_threshold, win.clap_min_interval) + assert dialog.threshold_spin.value() == DEFAULT_THRESHOLD + assert dialog.min_interval_spin.value() == DEFAULT_MIN_INTERVAL + + # -- Changing values and cancelling shouldn't touch MainWindow state + dialog.threshold_spin.setValue(0.3) + dialog.min_interval_spin.setValue(1.5) + dialog.reject() + assert win.clap_threshold == DEFAULT_THRESHOLD, "cancel should not have changed MainWindow state" + print("PASS: Cancel leaves settings untouched") + + # -- Changing values and accepting (simulated via the real handler) does + # update MainWindow state and gets used by the actual worker. + dialog2 = app_main.ClapSettingsDialog(win.clap_threshold, win.clap_min_interval) + dialog2.threshold_spin.setValue(0.3) # very sensitive + dialog2.min_interval_spin.setValue(0.1) + win.clap_threshold, win.clap_min_interval = dialog2.values() + assert win.clap_threshold == 0.3 + assert win.clap_min_interval == 0.1 + print("PASS: Accepting new values updates MainWindow state") + + # -- Reset to Defaults button restores the shared constants regardless + # of what was passed in (even if MainWindow itself had non-default + # settings already). + dialog3 = app_main.ClapSettingsDialog(0.3, 0.1) + assert dialog3.threshold_spin.value() == 0.3 + dialog3._reset_to_defaults() + assert dialog3.threshold_spin.value() == DEFAULT_THRESHOLD + assert dialog3.min_interval_spin.value() == DEFAULT_MIN_INTERVAL + print("PASS: Reset to Defaults restores DEFAULT_THRESHOLD/DEFAULT_MIN_INTERVAL") + + # -- End-to-end: a changed (very sensitive) threshold actually reaches + # the real DetectWorker/detect_claps() call for a real detection run. + win._load(CLIP) + qapp.processEvents() + time.sleep(0.2) + qapp.processEvents() + + win.clap_threshold = 0.3 + win.clap_min_interval = 0.1 + win.detect_all_button.click() + qapp.processEvents() + assert win._detect_worker.threshold == 0.3 + assert win._detect_worker.min_interval == 0.1 + ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp) + assert ok, "detection did not finish in time" + print("PASS: changed settings actually reach the DetectWorker for a real run") + + win.close() + qapp.processEvents() + print("\nCLAP SETTINGS SMOKE TEST PASSED") + + +if __name__ == "__main__": + main()