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