Add clap detection sensitivity settings (Tools menu)

Threshold and min-interval (the librosa delta/wait knobs) were only
ever tunable via the CLI's -t/-m flags. Added a Clap Detection
Settings dialog (Tools menu) with both as spin boxes, an explicit
Reset to Defaults button, and Cancel that leaves MainWindow's current
settings untouched.

Exposed DEFAULT_THRESHOLD=0.7/DEFAULT_MIN_INTERVAL=0.3 as named
constants in clap_detection.py so the CLI script, DetectWorker's
defaults, and the dialog's reset button all reference the same source
of truth instead of three copies of the same magic numbers that could
drift apart.
This commit is contained in:
ThePendulum
2026-07-26 05:56:50 +02:00
parent 97cbf38d97
commit ff127a8c21
4 changed files with 186 additions and 10 deletions
+8 -2
View File
@@ -19,6 +19,12 @@ class ClapDetectionUnavailable(RuntimeError):
pass 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) @dataclass(frozen=True)
class ClapEvent: class ClapEvent:
time_sec: float time_sec: float
@@ -109,8 +115,8 @@ def _extract_audio_wav(input_path: str, audio_stream_index: Optional[int] = None
def detect_claps( def detect_claps(
input_path: str, input_path: str,
start_offset: float = 0.0, start_offset: float = 0.0,
threshold: float = 0.7, threshold: float = DEFAULT_THRESHOLD,
min_interval: float = 0.3, min_interval: float = DEFAULT_MIN_INTERVAL,
max_results: Optional[int] = None, max_results: Optional[int] = None,
audio_stream_index: Optional[int] = None, audio_stream_index: Optional[int] = None,
) -> List[ClapEvent]: ) -> List[ClapEvent]:
+78 -3
View File
@@ -9,13 +9,65 @@ from PyQt6.QtGui import QShortcut, QKeySequence, QAction
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QDoubleSpinBox, QFileDialog, QPushButton, QLabel, QDoubleSpinBox, QFileDialog,
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget, QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget, QDialog, QDialogButtonBox, QFormLayout,
) )
from player_panel import PlayerPanel, format_timestamp from player_panel import PlayerPanel, format_timestamp
from sync_logic import compute_offset, SyncOffset from sync_logic import compute_offset, SyncOffset
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult 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): class ExportWorker(QThread):
@@ -45,12 +97,15 @@ class DetectWorker(QThread):
finished_ok = pyqtSignal(list) # List[ClapEvent] finished_ok = pyqtSignal(list) # List[ClapEvent]
finished_err = pyqtSignal(str) 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__() super().__init__()
self.input_path = input_path self.input_path = input_path
self.start_offset = start_offset self.start_offset = start_offset
self.max_results = max_results self.max_results = max_results
self.audio_stream_index = audio_stream_index self.audio_stream_index = audio_stream_index
self.threshold = threshold
self.min_interval = min_interval
def run(self): def run(self):
try: try:
@@ -59,6 +114,8 @@ class DetectWorker(QThread):
start_offset=self.start_offset, start_offset=self.start_offset,
max_results=self.max_results, max_results=self.max_results,
audio_stream_index=self.audio_stream_index, audio_stream_index=self.audio_stream_index,
threshold=self.threshold,
min_interval=self.min_interval,
) )
self.finished_ok.emit(events) self.finished_ok.emit(events)
except (ClapDetectionUnavailable, RuntimeError) as e: except (ClapDetectionUnavailable, RuntimeError) as e:
@@ -80,6 +137,8 @@ class MainWindow(QMainWindow):
self._preview_worker: Optional[ExportWorker] = None self._preview_worker: Optional[ExportWorker] = None
self._preview_temp_path: Optional[str] = None # currently loaded in preview_panel 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._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 # Two independent monitors (Kdenlive-style project/clip monitor
# tabs): each is a full player with its own timeline and transport, # 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) self.detect_next_action.triggered.connect(self._on_detect_next_clicked)
tools_menu.addAction(self.detect_next_action) 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): def _wire_shortcuts(self):
# These act on whichever tab (Source/Preview) is currently active -- # These act on whichever tab (Source/Preview) is currently active --
# each is a fully independent monitor with its own transport, so the # each is a fully independent monitor with its own transport, so the
@@ -385,6 +459,7 @@ class MainWindow(QMainWindow):
self._detect_worker = DetectWorker( self._detect_worker = DetectWorker(
self.input_path, start_offset=start_offset, max_results=max_results, self.input_path, start_offset=start_offset, max_results=max_results,
audio_stream_index=self._current_audio_stream_index(), 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_ok.connect(self._on_detect_done)
self._detect_worker.finished_err.connect(self._on_detect_failed) self._detect_worker.finished_err.connect(self._on_detect_failed)
+5 -5
View File
@@ -11,7 +11,7 @@ from datetime import timedelta
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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): def format_timestamp(seconds):
@@ -43,10 +43,10 @@ Threshold guide:
) )
parser.add_argument('input_file', help='Audio or video file') parser.add_argument('input_file', help='Audio or video file')
parser.add_argument('-t', '--threshold', type=float, default=0.7, parser.add_argument('-t', '--threshold', type=float, default=DEFAULT_THRESHOLD,
help='Detection threshold (0-1, default: 0.7, higher = less sensitive)') help=f'Detection threshold (0-1, default: {DEFAULT_THRESHOLD}, higher = less sensitive)')
parser.add_argument('-m', '--min-interval', type=float, default=0.3, parser.add_argument('-m', '--min-interval', type=float, default=DEFAULT_MIN_INTERVAL,
help='Minimum seconds between detections (default: 0.3)') help=f'Minimum seconds between detections (default: {DEFAULT_MIN_INTERVAL})')
parser.add_argument('-s', '--show-strength', action='store_true', parser.add_argument('-s', '--show-strength', action='store_true',
help='Show onset strength values') help='Show onset strength values')
+95
View File
@@ -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()