2 # SPDX-FileCopyrightText: 2011-2024 Filipe Coelho <falktx@falktx.com>
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 # ------------------------------------------------------------------------------------------------------------
8 from qt_compat import qt_config
11 from PyQt5.QtCore import pyqtSignal, pyqtSlot, QT_VERSION, Qt, QPointF, QRectF, QSize, QTimer
12 from PyQt5.QtGui import QColor, QPainter, QPen
13 from PyQt5.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent, QMainWindow
15 from PyQt6.QtCore import pyqtSignal, pyqtSlot, QT_VERSION, Qt, QPointF, QRectF, QSize, QTimer
16 from PyQt6.QtGui import QColor, QPainter, QPen
17 from PyQt6.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent, QMainWindow
19 # -----------------------------------------------------------------------
22 from carla_shared import *
23 from carla_utils import *
25 import ui_xycontroller
27 # -----------------------------------------------------------------------
28 # Imports (ExternalUI)
30 from carla_app import CarlaApplication
31 from externalui import ExternalUI
32 from widgets.paramspinbox import ParamSpinBox
34 # ------------------------------------------------------------------------------------------------------------
36 XYCONTROLLER_PARAMETER_X = 0
37 XYCONTROLLER_PARAMETER_Y = 1
39 # ------------------------------------------------------------------------------------------------------------
41 class XYGraphicsScene(QGraphicsScene):
43 cursorMoved = pyqtSignal(float,float)
45 def __init__(self, parent):
46 QGraphicsScene.__init__(self, parent)
53 self.m_mouseLock = False
58 self.setBackgroundBrush(Qt.black)
60 cursorPen = QPen(QColor(255, 255, 255), 2)
61 cursorBrush = QColor(255, 255, 255, 50)
62 self.m_cursor = self.addEllipse(QRectF(-10, -10, 20, 20), cursorPen, cursorBrush)
64 linePen = QPen(QColor(200, 200, 200, 100), 1, Qt.DashLine)
65 self.m_lineH = self.addLine(-9999, 0, 9999, 0, linePen)
66 self.m_lineV = self.addLine(0, -9999, 0, 9999, linePen)
68 self.p_size = QRectF(-100, -100, 100, 100)
70 # -------------------------------------------------------------------
72 def setControlX(self, x: int):
75 def setControlY(self, y: int):
78 def setChannels(self, channels):
79 self.m_channels = channels
81 def setPosX(self, x: float, forward: bool = True):
85 posX = x * (self.p_size.x() + self.p_size.width())
86 self.m_cursor.setPos(posX, self.m_cursor.y())
87 self.m_lineV.setX(posX)
90 value = posX / (self.p_size.x() + self.p_size.width());
91 self.sendMIDI(value, None)
93 self.m_smooth_x = posX;
95 def setPosY(self, y: float, forward: bool = True):
99 posY = y * (self.p_size.y() + self.p_size.height())
100 self.m_cursor.setPos(self.m_cursor.x(), posY)
101 self.m_lineH.setY(posY)
104 value = posY / (self.p_size.y() + self.p_size.height())
105 self.sendMIDI(None, value)
107 self.m_smooth_y = posY
109 def setSmooth(self, smooth: bool):
110 self.m_smooth = smooth
112 def setSmoothValues(self, x: float, y: float):
113 self.m_smooth_x = x * (self.p_size.x() + self.p_size.width());
114 self.m_smooth_y = y * (self.p_size.y() + self.p_size.height());
116 # -------------------------------------------------------------------
118 def updateSize(self, size: QSize):
119 self.p_size.setRect(-(float(size.width())/2),
120 -(float(size.height())/2),
124 def updateSmooth(self):
125 if not self.m_smooth:
128 if self.m_cursor.x() == self.m_smooth_x and self.m_cursor.y() == self.m_smooth_y:
132 if abs(self.m_cursor.x() - self.m_smooth_x) <= 0.0005:
133 self.m_smooth_x = self.m_cursor.x()
136 if abs(self.m_cursor.y() - self.m_smooth_y) <= 0.0005:
137 self.m_smooth_y = self.m_cursor.y()
143 newX = float(self.m_smooth_x + self.m_cursor.x()*7) / 8
144 newY = float(self.m_smooth_y + self.m_cursor.y()*7) / 8
145 pos = QPointF(newX, newY)
147 self.m_cursor.setPos(pos)
148 self.m_lineH.setY(pos.y())
149 self.m_lineV.setX(pos.x())
151 xp = pos.x() / (self.p_size.x() + self.p_size.width())
152 yp = pos.y() / (self.p_size.y() + self.p_size.height())
154 self.sendMIDI(xp, yp)
155 self.cursorMoved.emit(xp, yp)
157 # -------------------------------------------------------------------
159 def handleMousePos(self, pos: QPointF):
160 if not self.p_size.contains(pos):
161 if pos.x() < self.p_size.x():
162 pos.setX(self.p_size.x())
163 elif pos.x() > (self.p_size.x() + self.p_size.width()):
164 pos.setX(self.p_size.x() + self.p_size.width());
166 if pos.y() < self.p_size.y():
167 pos.setY(self.p_size.y())
168 elif pos.y() > (self.p_size.y() + self.p_size.height()):
169 pos.setY(self.p_size.y() + self.p_size.height())
171 self.m_smooth_x = pos.x()
172 self.m_smooth_y = pos.y()
174 if not self.m_smooth:
175 self.m_cursor.setPos(pos)
176 self.m_lineH.setY(pos.y())
177 self.m_lineV.setX(pos.x())
179 xp = pos.x() / (self.p_size.x() + self.p_size.width());
180 yp = pos.y() / (self.p_size.y() + self.p_size.height());
182 self.sendMIDI(xp, yp)
183 self.cursorMoved.emit(xp, yp)
185 def sendMIDI(self, xp, yp):
186 rate = float(0xff) / 4
187 msgd = ["cc2" if xp is not None and yp is not None else "cc"]
190 msgd.append(self.cc_x)
191 msgd.append(int(xp * rate + rate))
194 msgd.append(self.cc_y)
195 msgd.append(int(yp * rate + rate))
197 self.rparent.send(msgd)
199 # -------------------------------------------------------------------
201 def keyPressEvent(self, event): # QKeyEvent
204 def wheelEvent(self, event): # QGraphicsSceneWheelEvent
207 def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
208 self.m_mouseLock = True
209 self.handleMousePos(event.scenePos())
210 self.rparent.setCursor(Qt.CrossCursor)
211 QGraphicsScene.mousePressEvent(self, event);
213 def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent):
214 self.handleMousePos(event.scenePos())
215 QGraphicsScene.mouseMoveEvent(self, event);
217 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent):
218 self.m_mouseLock = False
219 self.rparent.setCursor(Qt.ArrowCursor)
220 QGraphicsScene.mouseReleaseEvent(self, event)
222 # -----------------------------------------------------------------------
225 class XYControllerUI(ExternalUI, QMainWindow):
227 ExternalUI.__init__(self)
228 QMainWindow.__init__(self)
229 self.ui = ui_xycontroller.Ui_XYControllerW()
230 self.ui.setupUi(self)
232 self.fSaveSizeNowChecker = -1
234 # ---------------------------------------------------------------
237 self.scene = XYGraphicsScene(self)
239 self.ui.dial_x.setImage(2)
240 self.ui.dial_y.setImage(2)
241 self.ui.dial_x.setLabel("X")
242 self.ui.dial_y.setLabel("Y")
243 self.ui.keyboard.setOctaves(10)
245 self.ui.graphicsView.setScene(self.scene)
246 self.ui.graphicsView.setRenderHints(QPainter.Antialiasing)
248 for MIDI_CC in MIDI_CC_LIST:
249 self.ui.cb_control_x.addItem(MIDI_CC)
250 self.ui.cb_control_y.addItem(MIDI_CC)
252 # ---------------------------------------------------------------
255 self.m_channels = [1]
257 self.ui.act_ch_01.setChecked(True)
258 self.ui.act_show_keyboard.setChecked(True)
259 self.ui.cb_control_y.setCurrentIndex(1)
261 # ---------------------------------------------------------------
262 # Connect actions to functions
264 self.scene.cursorMoved.connect(self.slot_sceneCursorMoved)
266 self.ui.keyboard.noteOn.connect(self.slot_noteOn)
267 self.ui.keyboard.noteOff.connect(self.slot_noteOff)
269 self.ui.cb_smooth.clicked.connect(self.slot_setSmooth)
271 self.ui.dial_x.realValueChanged.connect(self.slot_knobValueChangedX)
272 self.ui.dial_y.realValueChanged.connect(self.slot_knobValueChangedY)
274 if QT_VERSION >= 0x60000:
275 self.ui.cb_control_x.currentTextChanged.connect(self.slot_checkCC_X)
276 self.ui.cb_control_y.currentTextChanged.connect(self.slot_checkCC_Y)
278 self.ui.cb_control_x.currentIndexChanged[str].connect(self.slot_checkCC_X)
279 self.ui.cb_control_y.currentIndexChanged[str].connect(self.slot_checkCC_Y)
281 self.ui.act_ch_01.triggered.connect(self.slot_checkChannel)
282 self.ui.act_ch_02.triggered.connect(self.slot_checkChannel)
283 self.ui.act_ch_03.triggered.connect(self.slot_checkChannel)
284 self.ui.act_ch_04.triggered.connect(self.slot_checkChannel)
285 self.ui.act_ch_05.triggered.connect(self.slot_checkChannel)
286 self.ui.act_ch_06.triggered.connect(self.slot_checkChannel)
287 self.ui.act_ch_07.triggered.connect(self.slot_checkChannel)
288 self.ui.act_ch_08.triggered.connect(self.slot_checkChannel)
289 self.ui.act_ch_09.triggered.connect(self.slot_checkChannel)
290 self.ui.act_ch_10.triggered.connect(self.slot_checkChannel)
291 self.ui.act_ch_11.triggered.connect(self.slot_checkChannel)
292 self.ui.act_ch_12.triggered.connect(self.slot_checkChannel)
293 self.ui.act_ch_13.triggered.connect(self.slot_checkChannel)
294 self.ui.act_ch_14.triggered.connect(self.slot_checkChannel)
295 self.ui.act_ch_15.triggered.connect(self.slot_checkChannel)
296 self.ui.act_ch_16.triggered.connect(self.slot_checkChannel)
297 self.ui.act_ch_all.triggered.connect(self.slot_checkChannel_all)
298 self.ui.act_ch_none.triggered.connect(self.slot_checkChannel_none)
300 self.ui.act_show_keyboard.triggered.connect(self.slot_showKeyboard)
302 # ---------------------------------------------------------------
305 self.fIdleTimer = self.startTimer(60)
306 self.setWindowTitle(self.fUiName)
309 # -------------------------------------------------------------------
312 def slot_updateScreen(self):
313 self.ui.graphicsView.centerOn(0, 0)
314 self.scene.updateSize(self.ui.graphicsView.size())
316 dial_x = self.ui.dial_x.rvalue()
317 dial_y = self.ui.dial_y.rvalue()
318 self.scene.setPosX(dial_x / 100, False)
319 self.scene.setPosY(dial_y / 100, False)
320 self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
323 def slot_noteOn(self, note):
324 self.send(["note", True, note])
327 def slot_noteOff(self, note):
328 self.send(["note", False, note])
331 def slot_knobValueChangedX(self, x: float):
332 self.sendControl(XYCONTROLLER_PARAMETER_X, x)
333 self.scene.setPosX(x / 100, True)
334 self.scene.setSmoothValues(x / 100, self.ui.dial_y.rvalue() / 100)
337 def slot_knobValueChangedY(self, y: float):
338 self.sendControl(XYCONTROLLER_PARAMETER_Y, y)
339 self.scene.setPosY(y / 100, True)
340 self.scene.setSmoothValues(self.ui.dial_x.rvalue() / 100, y / 100)
343 def slot_checkCC_X(self, text: str):
347 cc_x = int(text.split(" ",1)[0])
349 self.scene.setControlX(cc_x)
350 self.sendConfigure("cc_x", str(cc_x))
353 def slot_checkCC_Y(self, text: str):
357 cc_y = int(text.split(" ",1)[0])
359 self.scene.setControlY(cc_y)
360 self.sendConfigure("cc_y", str(cc_y))
363 def slot_checkChannel(self, clicked):
364 if not self.sender():
367 channel = int(self.sender().text())
369 if clicked and channel not in self.m_channels:
370 self.m_channels.append(channel)
371 elif not clicked and channel in self.m_channels:
372 self.m_channels.remove(channel)
374 self.scene.setChannels(self.m_channels)
375 self.sendConfigure("channels", ",".join(str(c) for c in self.m_channels))
378 def slot_checkChannel_all(self):
379 self.ui.act_ch_01.setChecked(True)
380 self.ui.act_ch_02.setChecked(True)
381 self.ui.act_ch_03.setChecked(True)
382 self.ui.act_ch_04.setChecked(True)
383 self.ui.act_ch_05.setChecked(True)
384 self.ui.act_ch_06.setChecked(True)
385 self.ui.act_ch_07.setChecked(True)
386 self.ui.act_ch_08.setChecked(True)
387 self.ui.act_ch_09.setChecked(True)
388 self.ui.act_ch_10.setChecked(True)
389 self.ui.act_ch_11.setChecked(True)
390 self.ui.act_ch_12.setChecked(True)
391 self.ui.act_ch_13.setChecked(True)
392 self.ui.act_ch_14.setChecked(True)
393 self.ui.act_ch_15.setChecked(True)
394 self.ui.act_ch_16.setChecked(True)
396 self.m_channels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
397 self.scene.setChannels(self.m_channels)
398 self.sendConfigure("channels", ",".join(str(c) for c in self.m_channels))
401 def slot_checkChannel_none(self):
402 self.ui.act_ch_01.setChecked(False)
403 self.ui.act_ch_02.setChecked(False)
404 self.ui.act_ch_03.setChecked(False)
405 self.ui.act_ch_04.setChecked(False)
406 self.ui.act_ch_05.setChecked(False)
407 self.ui.act_ch_06.setChecked(False)
408 self.ui.act_ch_07.setChecked(False)
409 self.ui.act_ch_08.setChecked(False)
410 self.ui.act_ch_09.setChecked(False)
411 self.ui.act_ch_10.setChecked(False)
412 self.ui.act_ch_11.setChecked(False)
413 self.ui.act_ch_12.setChecked(False)
414 self.ui.act_ch_13.setChecked(False)
415 self.ui.act_ch_14.setChecked(False)
416 self.ui.act_ch_15.setChecked(False)
417 self.ui.act_ch_16.setChecked(False)
420 self.scene.setChannels(self.m_channels)
421 self.sendConfigure("channels", "")
424 def slot_setSmooth(self, smooth):
425 self.scene.setSmooth(smooth)
426 self.sendConfigure("smooth", "yes" if smooth else "no")
429 dial_x = self.ui.dial_x.rvalue()
430 dial_y = self.ui.dial_y.rvalue()
431 self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
433 @pyqtSlot(float, float)
434 def slot_sceneCursorMoved(self, xp: float, yp: float):
435 self.ui.dial_x.setValue(xp * 100, False)
436 self.ui.dial_y.setValue(yp * 100, False)
437 self.sendControl(XYCONTROLLER_PARAMETER_X, xp * 100)
438 self.sendControl(XYCONTROLLER_PARAMETER_Y, yp * 100)
441 def slot_showKeyboard(self, yesno):
442 self.ui.scrollArea.setVisible(yesno)
443 self.sendConfigure("show-midi-keyboard", "yes" if yesno else "no")
444 QTimer.singleShot(0, self.slot_updateScreen)
446 # -------------------------------------------------------------------
449 def dspParameterChanged(self, index: int, value: float):
450 if index == XYCONTROLLER_PARAMETER_X:
451 self.ui.dial_x.setValue(value, False)
452 self.scene.setPosX(value / 100, False)
453 elif index == XYCONTROLLER_PARAMETER_Y:
454 self.ui.dial_y.setValue(value, False)
455 self.scene.setPosY(value / 100, False)
459 self.scene.setSmoothValues(self.ui.dial_x.rvalue() / 100,
460 self.ui.dial_y.rvalue() / 100)
462 def dspStateChanged(self, key: str, value: str):
463 if key == "guiWidth":
470 self.resize(width, self.height())
472 elif key == "guiHeight":
479 self.resize(self.width(), height)
481 elif key == "smooth":
482 smooth = (value == "yes")
483 self.ui.cb_smooth.blockSignals(True)
484 self.ui.cb_smooth.setChecked(smooth)
485 self.ui.cb_smooth.blockSignals(False)
486 self.scene.setSmooth(smooth)
489 dial_x = self.ui.dial_x.rvalue()
490 dial_y = self.ui.dial_y.rvalue()
491 self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
493 elif key == "show-midi-keyboard":
494 show = (value == "yes")
495 self.ui.act_show_keyboard.blockSignals(True)
496 self.ui.act_show_keyboard.setChecked(show)
497 self.ui.act_show_keyboard.blockSignals(False)
498 self.ui.scrollArea.setVisible(show)
500 elif key == "channels":
502 self.m_channels = [int(c) for c in value.split(",")]
505 self.scene.setChannels(self.m_channels)
506 self.ui.act_ch_01.setChecked(bool(1 in self.m_channels))
507 self.ui.act_ch_02.setChecked(bool(2 in self.m_channels))
508 self.ui.act_ch_03.setChecked(bool(3 in self.m_channels))
509 self.ui.act_ch_04.setChecked(bool(4 in self.m_channels))
510 self.ui.act_ch_05.setChecked(bool(5 in self.m_channels))
511 self.ui.act_ch_06.setChecked(bool(6 in self.m_channels))
512 self.ui.act_ch_07.setChecked(bool(7 in self.m_channels))
513 self.ui.act_ch_08.setChecked(bool(8 in self.m_channels))
514 self.ui.act_ch_09.setChecked(bool(9 in self.m_channels))
515 self.ui.act_ch_10.setChecked(bool(10 in self.m_channels))
516 self.ui.act_ch_11.setChecked(bool(11 in self.m_channels))
517 self.ui.act_ch_12.setChecked(bool(12 in self.m_channels))
518 self.ui.act_ch_13.setChecked(bool(13 in self.m_channels))
519 self.ui.act_ch_14.setChecked(bool(14 in self.m_channels))
520 self.ui.act_ch_15.setChecked(bool(15 in self.m_channels))
521 self.ui.act_ch_16.setChecked(bool(16 in self.m_channels))
525 self.scene.setControlX(cc_x)
527 for cc_index in range(len(MIDI_CC_LIST)):
528 if cc_x == int(MIDI_CC_LIST[cc_index].split(" ",1)[0]):
529 self.ui.cb_control_x.blockSignals(True)
530 self.ui.cb_control_x.setCurrentIndex(cc_index)
531 self.ui.cb_control_x.blockSignals(False)
536 self.scene.setControlY(cc_y)
538 for cc_index in range(len(MIDI_CC_LIST)):
539 if cc_y == int(MIDI_CC_LIST[cc_index].split(" ",1)[0]):
540 self.ui.cb_control_y.blockSignals(True)
541 self.ui.cb_control_y.setCurrentIndex(cc_index)
542 self.ui.cb_control_y.blockSignals(False)
545 def dspNoteReceived(self, onOff, channel, note, velocity):
546 if channel+1 not in self.m_channels:
549 self.ui.keyboard.sendNoteOn(note, False)
551 self.ui.keyboard.sendNoteOff(note, False)
553 # -------------------------------------------------------------------
554 # ExternalUI Callbacks
560 self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
564 self.activateWindow()
570 self.closeExternalUI()
574 def uiTitleChanged(self, uiTitle):
575 self.setWindowTitle(uiTitle)
577 # -------------------------------------------------------------------
580 def showEvent(self, event):
581 self.slot_updateScreen()
582 QMainWindow.showEvent(self, event)
584 def resizeEvent(self, event):
585 self.fSaveSizeNowChecker = 0
586 self.slot_updateScreen()
587 QMainWindow.resizeEvent(self, event)
589 def timerEvent(self, event):
590 if event.timerId() == self.fIdleTimer:
591 self.idleExternalUI()
592 self.scene.updateSmooth()
594 if self.fSaveSizeNowChecker == 11:
595 self.sendConfigure("guiWidth", str(self.width()))
596 self.sendConfigure("guiHeight", str(self.height()))
597 self.fSaveSizeNowChecker = -1
598 elif self.fSaveSizeNowChecker >= 0:
599 self.fSaveSizeNowChecker += 1
601 QMainWindow.timerEvent(self, event)
603 def closeEvent(self, event):
604 self.closeExternalUI()
605 QMainWindow.closeEvent(self, event)
607 # there might be other qt windows open which will block the UI from quitting
610 #--------------- main ------------------
611 if __name__ == '__main__':
614 pathBinaries, _ = getPaths()
615 gCarla.utils = CarlaUtils(os.path.join(pathBinaries, "libcarla_utils." + DLL_EXTENSION))
616 gCarla.utils.set_process_name("XYController")
618 app = CarlaApplication("XYController")
619 gui = XYControllerUI()