Cleanup
[carla.git] / source / frontend / carla-plugin
blobf23b0356e24bbb9bd3af6251db235f8ea814550f
1 #!/usr/bin/env python3
2 # SPDX-FileCopyrightText: 2011-2024 Filipe Coelho <falktx@falktx.com>
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 # ------------------------------------------------------------------------------------------------------------
6 # Imports (Global)
8 from qt_compat import qt_config
10 if qt_config == 5:
11     from PyQt5.QtGui import QKeySequence, QMouseEvent
12     from PyQt5.QtWidgets import QFrame, QSplitter
13 elif qt_config == 6:
14     from PyQt6.QtGui import QKeySequence, QMouseEvent
15     from PyQt6.QtWidgets import QFrame, QSplitter
17 # ------------------------------------------------------------------------------------------------------------
18 # Imports (Custom Stuff)
20 from carla_backend_qt import CarlaHostQtPlugin
21 from carla_host import *
22 from externalui import ExternalUI
24 # ------------------------------------------------------------------------------------------------------------
25 # Host Plugin object
27 class PluginHost(CarlaHostQtPlugin):
28     def __init__(self):
29         CarlaHostQtPlugin.__init__(self)
31         if False:
32             # kdevelop likes this :)
33             self.fExternalUI = ExternalUI()
35         # ---------------------------------------------------------------
37         self.fExternalUI = None
39     # -------------------------------------------------------------------
41     def setExternalUI(self, extUI):
42         self.fExternalUI = extUI
44     def sendMsg(self, lines):
45         if self.fExternalUI is None:
46             return False
48         return self.fExternalUI.send(lines)
50     # -------------------------------------------------------------------
52     def engine_init(self, driverName, clientName):
53         return True
55     def engine_close(self):
56         return True
58     def engine_idle(self):
59         self.fExternalUI.idleExternalUI()
61     def is_engine_running(self):
62         if self.fExternalUI is None:
63             return False
65         return self.fExternalUI.isRunning()
67     def set_engine_about_to_close(self):
68         return True
70     def get_host_osc_url_tcp(self):
71         return self.tr("(OSC TCP port not provided in Plugin version)")
73 # ------------------------------------------------------------------------------------------------------------
74 # Main Window
76 class CarlaMiniW(ExternalUI, HostWindow):
77     def __init__(self, host, isPatchbay, parent=None):
78         ExternalUI.__init__(self)
79         HostWindow.__init__(self, host, isPatchbay, parent)
81         if False:
82             # kdevelop likes this :)
83             host = PluginHost()
85         self.host = host
87         host.setExternalUI(self)
89         self.fFirstInit = True
91         self.setWindowTitle(self.fUiName)
92         self.ready()
94     # Override this as it can be called from several places.
95     # We really need to close all UIs as events are driven by host idle which is only available when UI is visible
96     def closeExternalUI(self):
97         for i in reversed(range(self.fPluginCount)):
98             self.host.show_custom_ui(i, False)
100         ExternalUI.closeExternalUI(self)
102     # -------------------------------------------------------------------
103     # ExternalUI Callbacks
105     def uiShow(self):
106         if self.parent() is not None:
107             return
108         self.show()
110     def uiFocus(self):
111         if self.parent() is not None:
112             return
114         self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
115         self.show()
117         self.raise_()
118         self.activateWindow()
120     def uiHide(self):
121         if self.parent() is not None:
122             return
123         self.hide()
125     def uiQuit(self):
126         self.closeExternalUI()
127         self.close()
129         if self != gui:
130             gui.close()
132         # there might be other qt windows open which will block carla-plugin from quitting
133         app.quit()
135     def uiTitleChanged(self, uiTitle):
136         self.setWindowTitle(uiTitle)
138     # -------------------------------------------------------------------
139     # Qt events
141     def closeEvent(self, event):
142         self.closeExternalUI()
143         HostWindow.closeEvent(self, event)
145         # there might be other qt windows open which will block carla-plugin from quitting
146         app.quit()
148     # -------------------------------------------------------------------
149     # Custom callback
151     def msgCallback(self, msg):
152         try:
153             self.msgCallback2(msg)
154         except Exception as e:
155             print("msgCallback error, skipped for", msg, "error was:\n", e)
157     def msgCallback2(self, msg):
158         msg = charPtrToString(msg)
160         #if not msg:
161             #return
163         if msg == "runtime-info":
164             values = self.readlineblock().split(":")
165             load = float(values[0])
166             xruns = int(values[1])
167             self.host._set_runtime_info(load, xruns)
169         elif msg == "project-folder":
170             self.fProjectFilename = self.readlineblock()
172         elif msg == "transport":
173             playing = self.readlineblock_bool()
174             frame, bar, beat, tick = [int(i) for i in self.readlineblock().split(":")]
175             bpm = self.readlineblock_float()
176             self.host._set_transport(playing, frame, bar, beat, tick, bpm)
178         elif msg.startswith("PEAKS_"):
179             pluginId = int(msg.replace("PEAKS_", ""))
180             in1, in2, out1, out2 = [float(i) for i in self.readlineblock().split(":")]
181             self.host._set_peaks(pluginId, in1, in2, out1, out2)
183         elif msg.startswith("PARAMVAL_"):
184             pluginId, paramId = [int(i) for i in msg.replace("PARAMVAL_", "").split(":")]
185             paramValue = self.readlineblock_float()
186             if paramId < 0:
187                 self.host._set_internalValue(pluginId, paramId, paramValue)
188             else:
189                 self.host._set_parameterValue(pluginId, paramId, paramValue)
191         elif msg.startswith("ENGINE_CALLBACK_"):
192             action   = int(msg.replace("ENGINE_CALLBACK_", ""))
193             pluginId = self.readlineblock_int()
194             value1   = self.readlineblock_int()
195             value2   = self.readlineblock_int()
196             value3   = self.readlineblock_int()
197             valuef   = self.readlineblock_float()
198             valueStr = self.readlineblock()
200             self.host._setViaCallback(action, pluginId, value1, value2, value3, valuef, valueStr)
201             engineCallback(self.host, action, pluginId, value1, value2, value3, valuef, valueStr)
203         elif msg.startswith("ENGINE_OPTION_"):
204             option = int(msg.replace("ENGINE_OPTION_", ""))
205             forced = self.readlineblock_bool()
206             value  = self.readlineblock()
208             if self.fFirstInit and not forced:
209                 return
211             if option == ENGINE_OPTION_PROCESS_MODE:
212                 self.host.processMode = int(value)
213             elif option == ENGINE_OPTION_TRANSPORT_MODE:
214                 self.host.transportMode = int(value)
215             elif option == ENGINE_OPTION_FORCE_STEREO:
216                 self.host.forceStereo = bool(value == "true")
217             elif option == ENGINE_OPTION_PREFER_PLUGIN_BRIDGES:
218                 self.host.preferPluginBridges = bool(value == "true")
219             elif option == ENGINE_OPTION_PREFER_UI_BRIDGES:
220                 self.host.preferUIBridges = bool(value == "true")
221             elif option == ENGINE_OPTION_UIS_ALWAYS_ON_TOP:
222                 self.host.uisAlwaysOnTop = bool(value == "true")
223             elif option == ENGINE_OPTION_MAX_PARAMETERS:
224                 self.host.maxParameters = int(value)
225             elif option == ENGINE_OPTION_UI_BRIDGES_TIMEOUT:
226                 self.host.uiBridgesTimeout = int(value)
227             elif option == ENGINE_OPTION_PATH_BINARIES:
228                 self.host.pathBinaries = value
229             elif option == ENGINE_OPTION_PATH_RESOURCES:
230                 self.host.pathResources = value
232         elif msg.startswith("PLUGIN_INFO_"):
233             pluginId = int(msg.replace("PLUGIN_INFO_", ""))
234             self.host._add(pluginId)
236             type_, category, hints, uniqueId, optsAvail, optsEnabled = [int(i) for i in self.readlineblock().split(":")]
237             filename  = self.readlineblock()
238             name      = self.readlineblock()
239             iconName  = self.readlineblock()
240             realName  = self.readlineblock()
241             label     = self.readlineblock()
242             maker     = self.readlineblock()
243             copyright = self.readlineblock()
245             pinfo = {
246                 'type': type_,
247                 'category': category,
248                 'hints': hints,
249                 'optionsAvailable': optsAvail,
250                 'optionsEnabled': optsEnabled,
251                 'filename': filename,
252                 'name':  name,
253                 'label': label,
254                 'maker': maker,
255                 'copyright': copyright,
256                 'iconName': iconName,
257                 'patchbayClientId': 0,
258                 'uniqueId': uniqueId
259             }
260             self.host._set_pluginInfo(pluginId, pinfo)
261             self.host._set_pluginRealName(pluginId, realName)
263         elif msg.startswith("AUDIO_COUNT_"):
264             pluginId, ins, outs = [int(i) for i in msg.replace("AUDIO_COUNT_", "").split(":")]
265             self.host._set_audioCountInfo(pluginId, {'ins': ins, 'outs': outs})
267         elif msg.startswith("MIDI_COUNT_"):
268             pluginId, ins, outs = [int(i) for i in msg.replace("MIDI_COUNT_", "").split(":")]
269             self.host._set_midiCountInfo(pluginId, {'ins': ins, 'outs': outs})
271         elif msg.startswith("PARAMETER_COUNT_"):
272             pluginId, ins, outs, count = [int(i) for i in msg.replace("PARAMETER_COUNT_", "").split(":")]
273             self.host._set_parameterCountInfo(pluginId, count, {'ins': ins, 'outs': outs})
275         elif msg.startswith("PARAMETER_DATA_"):
276             pluginId, paramId = [int(i) for i in msg.replace("PARAMETER_DATA_", "").split(":")]
277             paramType, paramHints, mappedControlIndex, midiChannel = [int(i) for i in self.readlineblock().split(":")]
278             mappedMinimum, mappedMaximum = [float(i) for i in self.readlineblock().split(":")]
279             paramName = self.readlineblock()
280             paramUnit = self.readlineblock()
281             paramComment = self.readlineblock()
282             paramGroupName = self.readlineblock()
284             paramInfo = {
285                 'name': paramName,
286                 'symbol': "",
287                 'unit': paramUnit,
288                 'comment': paramComment,
289                 'groupName': paramGroupName,
290                 'scalePointCount': 0,
291             }
292             self.host._set_parameterInfo(pluginId, paramId, paramInfo)
294             paramData = {
295                 'type': paramType,
296                 'hints': paramHints,
297                 'index': paramId,
298                 'rindex': -1,
299                 'midiChannel': midiChannel,
300                 'mappedControlIndex': mappedControlIndex,
301                 'mappedMinimum': mappedMinimum,
302                 'mappedMaximum': mappedMaximum,
303             }
304             self.host._set_parameterData(pluginId, paramId, paramData)
306         elif msg.startswith("PARAMETER_RANGES_"):
307             pluginId, paramId = [int(i) for i in msg.replace("PARAMETER_RANGES_", "").split(":")]
308             def_, min_, max_, step, stepSmall, stepLarge = [float(i) for i in self.readlineblock().split(":")]
310             paramRanges = {
311                 'def': def_,
312                 'min': min_,
313                 'max': max_,
314                 'step': step,
315                 'stepSmall': stepSmall,
316                 'stepLarge': stepLarge
317             }
318             self.host._set_parameterRanges(pluginId, paramId, paramRanges)
320         elif msg.startswith("PROGRAM_COUNT_"):
321             pluginId, count, current = [int(i) for i in msg.replace("PROGRAM_COUNT_", "").split(":")]
322             self.host._set_programCount(pluginId, count)
323             self.host._set_currentProgram(pluginId, current)
325         elif msg.startswith("PROGRAM_NAME_"):
326             pluginId, progId = [int(i) for i in msg.replace("PROGRAM_NAME_", "").split(":")]
327             progName = self.readlineblock()
328             self.host._set_programName(pluginId, progId, progName)
330         elif msg.startswith("MIDI_PROGRAM_COUNT_"):
331             pluginId, count, current = [int(i) for i in msg.replace("MIDI_PROGRAM_COUNT_", "").split(":")]
332             self.host._set_midiProgramCount(pluginId, count)
333             self.host._set_currentMidiProgram(pluginId, current)
335         elif msg.startswith("MIDI_PROGRAM_DATA_"):
336             pluginId, midiProgId = [int(i) for i in msg.replace("MIDI_PROGRAM_DATA_", "").split(":")]
337             bank, program = [int(i) for i in self.readlineblock().split(":")]
338             name = self.readlineblock()
339             self.host._set_midiProgramData(pluginId, midiProgId, {'bank': bank, 'program': program, 'name': name})
341         elif msg.startswith("CUSTOM_DATA_COUNT_"):
342             pluginId, count = [int(i) for i in msg.replace("CUSTOM_DATA_COUNT_", "").split(":")]
343             self.host._set_customDataCount(pluginId, count)
345         elif msg.startswith("CUSTOM_DATA_"):
346             pluginId, customDataId = [int(i) for i in msg.replace("CUSTOM_DATA_", "").split(":")]
348             type_ = self.readlineblock()
349             key   = self.readlineblock()
350             value = self.readlineblock()
351             self.host._set_customData(pluginId, customDataId, {'type': type_, 'key': key, 'value': value})
353         elif msg == "osc-urls":
354             tcp = self.readlineblock()
355             udp = self.readlineblock()
356             self.host.fOscTCP = tcp
357             self.host.fOscUDP = udp
359         elif msg == "max-plugin-number":
360             maxnum = self.readlineblock_int()
361             self.host.fMaxPluginNumber = maxnum
363         elif msg == "buffer-size":
364             bufsize = self.readlineblock_int()
365             self.host.fBufferSize = bufsize
367         elif msg == "sample-rate":
368             srate = self.readlineblock_float()
369             self.host.fSampleRate = srate
371         elif msg == "error":
372             error = self.readlineblock()
373             engineCallback(self.host, ENGINE_CALLBACK_ERROR, 0, 0, 0, 0, 0.0, error)
375         elif msg == "show":
376             self.fFirstInit = False
377             self.uiShow()
379         elif msg == "focus":
380             self.uiFocus()
382         elif msg == "hide":
383             self.uiHide()
385         elif msg == "quit":
386             self.fQuitReceived = True
387             self.uiQuit()
389         elif msg == "uiTitle":
390             uiTitle = self.readlineblock()
391             self.uiTitleChanged(uiTitle)
393         else:
394             print("unknown message: \"" + msg + "\"")
396 # ------------------------------------------------------------------------------------------------------------
397 # Embed Widget
399 class QEmbedWidget(QWidget):
400     def __init__(self, winId):
401         QWidget.__init__(self)
402         self.setAttribute(Qt.WA_LayoutUsesWidgetRect)
403         self.move(0, 0)
405         self.fPos = (0, 0)
406         self.fWinId = 0
408     def finalSetup(self, gui, winId):
409         self.fWinId = int(self.winId())
410         gui.ui.centralwidget.installEventFilter(self)
411         gui.ui.menubar.installEventFilter(self)
412         gCarla.utils.x11_reparent_window(self.fWinId, winId)
413         self.show()
415     def fixPosition(self):
416         pos = gCarla.utils.x11_get_window_pos(self.fWinId)
417         if self.fPos == pos:
418             return
419         self.fPos = pos
420         self.move(pos[0], pos[1])
421         gCarla.utils.x11_move_window(self.fWinId, pos[2], pos[3])
423     def eventFilter(self, obj, ev):
424         if isinstance(ev, QMouseEvent):
425             self.fixPosition()
426         return False
428     def enterEvent(self, ev):
429         self.fixPosition()
430         QWidget.enterEvent(self, ev)
432 # ------------------------------------------------------------------------------------------------------------
433 # Embed plugin UI
435 class CarlaEmbedW(QEmbedWidget):
436     def __init__(self, host, winId, isPatchbay):
437         QEmbedWidget.__init__(self, winId)
439         if False:
440             host = CarlaHostPlugin()
442         self.host = host
443         self.fWinId = winId
444         self.setFixedSize(1024, 712)
446         self.fLayout = QVBoxLayout(self)
447         self.fLayout.setContentsMargins(0, 0, 0, 0)
448         self.fLayout.setSpacing(0)
449         self.setLayout(self.fLayout)
451         self.gui = CarlaMiniW(host, isPatchbay, self)
452         self.gui.hide()
454         self.gui.ui.act_file_quit.setEnabled(False)
455         self.gui.ui.act_file_quit.setVisible(False)
457         self.fShortcutActions = []
458         self.addShortcutActions(self.gui.ui.menu_File.actions())
459         self.addShortcutActions(self.gui.ui.menu_Plugin.actions())
460         self.addShortcutActions(self.gui.ui.menu_PluginMacros.actions())
461         self.addShortcutActions(self.gui.ui.menu_Settings.actions())
462         self.addShortcutActions(self.gui.ui.menu_Help.actions())
464         if self.host.processMode == ENGINE_PROCESS_MODE_PATCHBAY:
465             self.addShortcutActions(self.gui.ui.menu_Canvas.actions())
466             self.addShortcutActions(self.gui.ui.menu_Canvas_Zoom.actions())
468         self.addWidget(self.gui.ui.menubar)
469         self.addLine()
470         self.addWidget(self.gui.ui.toolBar)
472         if self.host.processMode == ENGINE_PROCESS_MODE_PATCHBAY:
473             self.addLine()
475         self.fCentralSplitter = QSplitter(self)
476         policy = self.fCentralSplitter.sizePolicy()
477         policy.setVerticalStretch(1)
478         self.fCentralSplitter.setSizePolicy(policy)
480         self.addCentralWidget(self.gui.ui.dockWidget)
481         self.addCentralWidget(self.gui.centralWidget())
482         self.fLayout.addWidget(self.fCentralSplitter)
484         self.finalSetup(self.gui, winId)
486     def addShortcutActions(self, actions):
487         for action in actions:
488             if not action.shortcut().isEmpty():
489                 self.fShortcutActions.append(action)
491     def addWidget(self, widget):
492         widget.setParent(self)
493         self.fLayout.addWidget(widget)
495     def addCentralWidget(self, widget):
496         widget.setParent(self)
497         self.fCentralSplitter.addWidget(widget)
499     def addLine(self):
500         line = QFrame(self)
501         line.setFrameShadow(QFrame.Sunken)
502         line.setFrameShape(QFrame.HLine)
503         line.setLineWidth(0)
504         line.setMidLineWidth(1)
505         self.fLayout.addWidget(line)
507     def keyPressEvent(self, event):
508         modifiers    = event.modifiers()
509         modifiersStr = ""
511         if modifiers & Qt.ShiftModifier:
512             modifiersStr += "Shift+"
513         if modifiers & Qt.ControlModifier:
514             modifiersStr += "Ctrl+"
515         if modifiers & Qt.AltModifier:
516             modifiersStr += "Alt+"
517         if modifiers & Qt.MetaModifier:
518             modifiersStr += "Meta+"
520         keyStr = QKeySequence(event.key()).toString()
521         keySeq = QKeySequence(modifiersStr + keyStr)
523         for action in self.fShortcutActions:
524             if not action.isEnabled():
525                 continue
526             if keySeq.matches(action.shortcut()) != QKeySequence.ExactMatch:
527                 continue
528             event.accept()
529             action.trigger()
530             return
532         QEmbedWidget.keyPressEvent(self, event)
534     def showEvent(self, event):
535         QEmbedWidget.showEvent(self, event)
537         if QT_VERSION >= 0x50600:
538             self.host.set_engine_option(ENGINE_OPTION_FRONTEND_UI_SCALE, int(self.devicePixelRatioF() * 1000), "")
539             print("Plugin UI pixel ratio is", self.devicePixelRatioF(),
540                   "with %ix%i" % (self.width(), self.height()), "in size")
542         # set our gui as parent for all plugins UIs
543         if self.host.manageUIs:
544             if MACOS:
545                 nsViewPtr = int(self.fWinId)
546                 winIdStr  = "%x" % gCarla.utils.cocoa_get_window(nsViewPtr)
547             else:
548                 winIdStr = "%x" % int(self.fWinId)
549             self.host.set_engine_option(ENGINE_OPTION_FRONTEND_WIN_ID, 0, winIdStr)
551     def hideEvent(self, event):
552         # disable parent
553         self.host.set_engine_option(ENGINE_OPTION_FRONTEND_WIN_ID, 0, "0")
555         QEmbedWidget.hideEvent(self, event)
557     def closeEvent(self, event):
558         self.gui.close()
559         self.gui.closeExternalUI()
560         QEmbedWidget.closeEvent(self, event)
562         # there might be other qt windows open which will block carla-plugin from quitting
563         app.quit()
565     def setLoadRDFsNeeded(self):
566         self.gui.setLoadRDFsNeeded()
568 # ------------------------------------------------------------------------------------------------------------
569 # Main
571 if __name__ == '__main__':
572     import resources_rc
574     # -------------------------------------------------------------
575     # Get details regarding target usage
577     try:
578         winId = int(os.getenv("CARLA_PLUGIN_EMBED_WINID"))
579     except:
580         winId = 0
582     usingEmbed = bool(LINUX and winId != 0)
584     # -------------------------------------------------------------
585     # Init host backend (part 1)
587     isPatchbay = sys.argv[0].rsplit(os.path.sep)[-1].lower().replace(".exe","") == "carla-plugin-patchbay"
589     host = initHost("Carla-Plugin", None, False, True, True, PluginHost)
590     host.processMode       = ENGINE_PROCESS_MODE_PATCHBAY if isPatchbay else ENGINE_PROCESS_MODE_CONTINUOUS_RACK
591     host.processModeForced = True
592     host.nextProcessMode   = host.processMode
594     # -------------------------------------------------------------
595     # Set-up environment
597     gCarla.utils.setenv("CARLA_PLUGIN_EMBED_WINID", "0")
599     if usingEmbed:
600         gCarla.utils.setenv("QT_QPA_PLATFORM", "xcb")
602     # -------------------------------------------------------------
603     # App initialization
605     app = CarlaApplication("Carla2-Plugin")
607     # -------------------------------------------------------------
608     # Set-up custom signal handling
610     setUpSignals()
612     # -------------------------------------------------------------
613     # Init host backend (part 2)
615     loadHostSettings(host)
617     # -------------------------------------------------------------
618     # Create GUI
620     if usingEmbed:
621         gui = CarlaEmbedW(host, winId, isPatchbay)
622     else:
623         gui = CarlaMiniW(host, isPatchbay)
625     # -------------------------------------------------------------
626     # App-Loop
628     app.exit_exec()