Audio plugin host https://kx.studio/carla
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

632 lines
22KB

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