diff --git a/friture/Plot.qml b/friture/Plot.qml index 217d0088..d8a2def7 100644 --- a/friture/Plot.qml +++ b/friture/Plot.qml @@ -14,6 +14,8 @@ Rectangle { required property ScopeData scopedata default property alias content: plotItemPlaceholder.children + signal pointSelected(real x, real y) + GridLayout { anchors.fill: parent rowSpacing: 2 @@ -68,6 +70,8 @@ Rectangle { id: plotItemPlaceholder anchors.fill: parent } + + onPointSelected: (x, y) => plot.pointSelected(x, y) } Item { diff --git a/friture/PlotArea.qml b/friture/PlotArea.qml index 30a09abf..8252ec90 100644 --- a/friture/PlotArea.qml +++ b/friture/PlotArea.qml @@ -11,6 +11,8 @@ Item { default property alias content: plotItemPlaceholder.children + signal pointSelected(real x, real y) + PlotBackground { anchors.fill: parent } @@ -40,7 +42,7 @@ Item { Item { id: crosshair - visible: plotMouseArea.pressed + visible: plotMouseArea.pressed && plotMouseArea.pressedButtons & Qt.LeftButton anchors.fill: parent property double posX: Math.min(Math.max(plotMouseArea.mouseX, 0), scopePlotArea.width) @@ -83,6 +85,18 @@ Item { MouseArea { id: plotMouseArea anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.CrossCursor + + onClicked: (event) => { + if (event.button == Qt.RightButton) { + scopePlotArea.pointSelected( + scopePlotArea.horizontal_axis.coordinate_transform.toPlot( + crosshair.relativePosX), + scopePlotArea.vertical_axis.coordinate_transform.toPlot( + 1. - crosshair.relativePosY) + ); + } + } } } diff --git a/friture/Scope.qml b/friture/Scope.qml index e3db2b7c..401b5d54 100644 --- a/friture/Scope.qml +++ b/friture/Scope.qml @@ -9,6 +9,8 @@ Item { id: container property var stateId + signal pointSelected(real x, real y) + // delay the load of the Plot until stateId has been set Loader { id: loader @@ -34,6 +36,8 @@ Item { curve: modelData } } + + onPointSelected: (x, y) => container.pointSelected(x, y) } } } diff --git a/friture/dock.py b/friture/dock.py index 1185964b..96a2132c 100644 --- a/friture/dock.py +++ b/friture/dock.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from friture.analyzer import Friture from friture.dockmanager import DockManager + from friture.playback.control import PlaybackControlWidget from PyQt5.QtQml import QQmlEngine @@ -43,6 +44,7 @@ def __init__( self.dockmanager: 'DockManager' = parent.dockmanager self.audiobuffer = parent.audiobuffer + self.playback_widget: 'PlaybackControlWidget' = parent.playback_widget self.setObjectName(name) @@ -109,6 +111,10 @@ def widget_select(self, widgetId: int) -> None: self.audiowidget = constructor(self) assert self.audiowidget is not None # mypy can't prove this :( + if hasattr(self.audiowidget, 'connect_time_selected'): + self.audiowidget.connect_time_selected( + self.playback_widget.on_time_selected) + # audiowidget is duck typed for this: self.audiowidget.set_buffer(self.audiobuffer) # type: ignore self.audiobuffer.new_data_available.connect( diff --git a/friture/pitch_tracker.py b/friture/pitch_tracker.py index a53472e6..98fd239c 100644 --- a/friture/pitch_tracker.py +++ b/friture/pitch_tracker.py @@ -27,7 +27,7 @@ from PyQt5.QtQuick import QQuickWindow from PyQt5.QtQuickWidgets import QQuickWidget from PyQt5.QtQml import QQmlComponent, QQmlEngine -from typing import Any, Optional +from typing import Any, Callable, Optional from friture.audiobackend import SAMPLING_RATE from friture.audiobuffer import AudioBuffer @@ -67,6 +67,9 @@ def format_frequency(freq: float) -> str: class PitchTrackerWidget(QtWidgets.QWidget): + # x=time is negative from present, y=frequency in Hz + point_selected = pyqtSignal(float, float) + def __init__(self, parent: QtWidgets.QWidget, engine: QQmlEngine): super().__init__(parent) @@ -109,6 +112,7 @@ def __init__(self, parent: QtWidgets.QWidget, engine: QQmlEngine): root: Any = self.quickWidget.rootObject() root.setProperty("stateId", state_id) + root.pointSelected.connect(self.on_point_selected) self.gridLayout.addWidget(self.quickWidget, 0, 0, 1, 1) @@ -173,6 +177,12 @@ def on_status_changed(self, status: QQuickWidget.Status) -> None: for error in self.quickWidget.errors(): self.logger.error("QML error: " + error.toString()) + def on_point_selected(self, x: float, y: float) -> None: + self.point_selected.emit(x, y) + + def connect_time_selected(self, slot: Callable[[float], None]) -> None: + self.point_selected.connect(lambda t, _f: slot(t)) + # method def canvasUpdate(self) -> None: # nothing to do here diff --git a/friture/playback/control.py b/friture/playback/control.py index dff2faf1..436b7520 100644 --- a/friture/playback/control.py +++ b/friture/playback/control.py @@ -93,8 +93,16 @@ def on_playback_stopped(self) -> None: self.root.setPlaybackPosition(self.player.play_start_time) def on_playback_position_changed(self, value: float) -> None: + # This handles changes in the slider self.player.play_start_time = value + def on_time_selected(self, time: float) -> None: + # This handles clicks on plot widgets, i.e. the slider also needs + # to be updated. + time = max(time, -self.player.recorded_len_sec) + self.root.setPlaybackPosition(time) + self.on_playback_position_changed(time) + def on_recorded_len_changed(self, length: float) -> None: # Always give the slider a nonzero length even if nothing is recorded self.root.setRecordingStartTime(-max(length, 0.1)) diff --git a/friture/playback/player.py b/friture/playback/player.py index ed680888..778b9512 100644 --- a/friture/playback/player.py +++ b/friture/playback/player.py @@ -97,7 +97,11 @@ def handle_new_data(self, data: np.ndarray) -> None: self.recorded_len + data.shape[1], self.history_samples) if new_len != self.recorded_len: self.recorded_len = new_len - self.recorded_length_changed.emit(self.recorded_len / SAMPLING_RATE) + self.recorded_length_changed.emit(self.recorded_len_sec) + + @property + def recorded_len_sec(self) -> float: + return self.recorded_len / SAMPLING_RATE def play(self) -> None: if self.state != PlayState.STOPPED: diff --git a/friture/plotting/canvasWidget.py b/friture/plotting/canvasWidget.py index 0c56e51d..40740de1 100644 --- a/friture/plotting/canvasWidget.py +++ b/friture/plotting/canvasWidget.py @@ -25,6 +25,7 @@ class CanvasWidget(QtWidgets.QWidget): resized = QtCore.pyqtSignal(int, int) + point_selected = QtCore.pyqtSignal(float, float) def __init__(self, parent, verticalScaleTransform, horizontalScaleTransform): super(CanvasWidget, self).__init__(parent) @@ -169,6 +170,11 @@ def mouseReleaseEvent(self, event): self.ruler = False # ask for update so the the ruler is actually erased self.update() + if event.button() == QtCore.Qt.RightButton: + self.point_selected.emit( + self.horizontalScaleTransform.toPlot(event.x()), + self.verticalScaleTransform.toPlot(float(self.height() - event.y())) + ) def mouseMoveEvent(self, event): if event.buttons() & QtCore.Qt.LeftButton: diff --git a/friture/spectrogram.py b/friture/spectrogram.py index b040520f..6a542cfa 100644 --- a/friture/spectrogram.py +++ b/friture/spectrogram.py @@ -20,7 +20,10 @@ """Spectrogram widget, that displays a rolling 2D image of the time-frequency spectrum.""" from PyQt5 import QtWidgets +import PyQt5.QtCore as QtCore from numpy import log10, floor, zeros, float64, tile, array +from typing import Callable + from friture.imageplot import ImagePlot from friture.audioproc import audioproc # audio processing class from friture.spectrogram_settings import (Spectrogram_Settings_Dialog, # settings dialog @@ -39,6 +42,8 @@ class Spectrogram_Widget(QtWidgets.QWidget): + # x=time is age, or (negative) distance from right edge + point_selected = QtCore.pyqtSignal(float, float) def __init__(self, parent): super().__init__(parent) @@ -90,6 +95,9 @@ def __init__(self, parent): AudioBackend().underflow.connect(self.PlotZoneImage.plotImage.canvasscaledspectrogram.syncOffsets) + self.PlotZoneImage.canvasWidget.point_selected.connect( + self.on_point_selected) + self.last_data_time = 0. self.mustRestart = False @@ -177,6 +185,12 @@ def restart(self): # defer the restart until we get data from the audio source (so that a fresh lastdatatime is passed to the spectrogram image) self.mustRestart = True + def on_point_selected(self, time: float, freq: float) -> None: + self.point_selected.emit(time - self.timerange_s, freq) + + def connect_time_selected(self, slot: Callable[[float], None]) -> None: + self.point_selected.connect(lambda t, _f: slot(t)) + def setminfreq(self, freq): self.minfreq = freq self.PlotZoneImage.setfreqrange(self.minfreq, self.maxfreq)