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:
+8
-2
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user