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.

host.py 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. # Carla bridge for LV2 modguis
  4. # Copyright (C) 2015-2019 Filipe Coelho <falktx@falktx.com>
  5. #
  6. # This program is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU General Public License as
  8. # published by the Free Software Foundation; either version 2 of
  9. # the License, or any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # For a full copy of the GNU General Public License see the doc/GPL.txt file.
  17. # ------------------------------------------------------------------------------------------------------------
  18. # Imports (Global)
  19. from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QPoint, QSize, QUrl
  20. from PyQt5.QtGui import QImage, QPainter, QPalette
  21. from PyQt5.QtWidgets import QApplication, QMainWindow
  22. from PyQt5.QtWebKit import QWebSettings
  23. from PyQt5.QtWebKitWidgets import QWebView
  24. import sys
  25. # ------------------------------------------------------------------------------------------------------------
  26. # Imports (Custom)
  27. from carla_host import charPtrToString, gCarla
  28. from .webserver import WebServerThread, PORT
  29. # ------------------------------------------------------------------------------------------------------------
  30. # Imports (MOD)
  31. from modtools.utils import get_plugin_info, init as lv2_init
  32. # ------------------------------------------------------------------------------------------------------------
  33. # Host Window
  34. class HostWindow(QMainWindow):
  35. # signals
  36. SIGTERM = pyqtSignal()
  37. SIGUSR1 = pyqtSignal()
  38. # --------------------------------------------------------------------------------------------------------
  39. def __init__(self):
  40. QMainWindow.__init__(self)
  41. gCarla.gui = self
  42. URI = sys.argv[1]
  43. # ----------------------------------------------------------------------------------------------------
  44. # Internal stuff
  45. self.fCurrentFrame = None
  46. self.fDocElemement = None
  47. self.fCanSetValues = False
  48. self.fNeedsShow = False
  49. self.fSizeSetup = False
  50. self.fQuitReceived = False
  51. self.fWasRepainted = False
  52. lv2_init()
  53. self.fPlugin = get_plugin_info(URI)
  54. self.fPorts = self.fPlugin['ports']
  55. self.fPortSymbols = {}
  56. self.fPortValues = {}
  57. for port in self.fPorts['control']['input']:
  58. self.fPortSymbols[port['index']] = (port['symbol'], False)
  59. self.fPortValues [port['index']] = port['ranges']['default']
  60. for port in self.fPorts['control']['output']:
  61. self.fPortSymbols[port['index']] = (port['symbol'], True)
  62. self.fPortValues [port['index']] = port['ranges']['default']
  63. # ----------------------------------------------------------------------------------------------------
  64. # Init pipe
  65. if len(sys.argv) == 7:
  66. self.fPipeClient = gCarla.utils.pipe_client_new(lambda s,msg: self.msgCallback(msg))
  67. else:
  68. self.fPipeClient = None
  69. # ----------------------------------------------------------------------------------------------------
  70. # Init Web server
  71. self.fWebServerThread = WebServerThread(self)
  72. self.fWebServerThread.start()
  73. # ----------------------------------------------------------------------------------------------------
  74. # Set up GUI
  75. self.setContentsMargins(0, 0, 0, 0)
  76. self.fWebview = QWebView(self)
  77. #self.fWebview.setAttribute(Qt.WA_OpaquePaintEvent, False)
  78. #self.fWebview.setAttribute(Qt.WA_TranslucentBackground, True)
  79. self.setCentralWidget(self.fWebview)
  80. page = self.fWebview.page()
  81. page.setViewportSize(QSize(980, 600))
  82. mainFrame = page.mainFrame()
  83. mainFrame.setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
  84. mainFrame.setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
  85. palette = self.fWebview.palette()
  86. palette.setBrush(QPalette.Base, palette.brush(QPalette.Window))
  87. page.setPalette(palette)
  88. self.fWebview.setPalette(palette)
  89. settings = self.fWebview.settings()
  90. settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
  91. self.fWebview.loadFinished.connect(self.slot_webviewLoadFinished)
  92. url = "http://127.0.0.1:%s/icon.html#%s" % (PORT, URI)
  93. print("url:", url)
  94. self.fWebview.load(QUrl(url))
  95. # ----------------------------------------------------------------------------------------------------
  96. # Connect actions to functions
  97. self.SIGTERM.connect(self.slot_handleSIGTERM)
  98. # ----------------------------------------------------------------------------------------------------
  99. # Final setup
  100. self.fIdleTimer = self.startTimer(30)
  101. if self.fPipeClient is None:
  102. # testing, show UI only
  103. self.setWindowTitle("TestUI")
  104. self.fNeedsShow = True
  105. # --------------------------------------------------------------------------------------------------------
  106. def closeExternalUI(self):
  107. self.fWebServerThread.stopWait()
  108. if self.fPipeClient is None:
  109. return
  110. if not self.fQuitReceived:
  111. self.send(["exiting"])
  112. gCarla.utils.pipe_client_destroy(self.fPipeClient)
  113. self.fPipeClient = None
  114. def idleStuff(self):
  115. if self.fPipeClient is not None:
  116. gCarla.utils.pipe_client_idle(self.fPipeClient)
  117. self.checkForRepaintChanges()
  118. if self.fSizeSetup:
  119. return
  120. if self.fDocElemement is None or self.fDocElemement.isNull():
  121. return
  122. pedal = self.fDocElemement.findFirst(".mod-pedal")
  123. if pedal.isNull():
  124. return
  125. size = pedal.geometry().size()
  126. if size.width() <= 10 or size.height() <= 10:
  127. return
  128. # render web frame to image
  129. image = QImage(self.fWebview.page().viewportSize(), QImage.Format_ARGB32_Premultiplied)
  130. image.fill(Qt.transparent)
  131. painter = QPainter(image)
  132. self.fCurrentFrame.render(painter)
  133. painter.end()
  134. #image.save("/tmp/test.png")
  135. # get coordinates and size from image
  136. #x = -1
  137. #y = -1
  138. #lastx = -1
  139. #lasty = -1
  140. #bgcol = self.fHostColor.rgba()
  141. #for h in range(0, image.height()):
  142. #hasNonTransPixels = False
  143. #for w in range(0, image.width()):
  144. #if image.pixel(w, h) not in (0, bgcol): # 0xff070707):
  145. #hasNonTransPixels = True
  146. #if x == -1 or x > w:
  147. #x = w
  148. #lastx = max(lastx, w)
  149. #if hasNonTransPixels:
  150. ##if y == -1:
  151. ##y = h
  152. #lasty = h
  153. # set size and position accordingly
  154. #if -1 not in (x, lastx, lasty):
  155. #self.setFixedSize(lastx-x, lasty)
  156. #self.fCurrentFrame.setScrollPosition(QPoint(x, 0))
  157. #else:
  158. # TODO that^ needs work
  159. if True:
  160. self.setFixedSize(size)
  161. # set initial values
  162. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue(':bypass', 0, null)")
  163. for index in self.fPortValues.keys():
  164. symbol, isOutput = self.fPortSymbols[index]
  165. value = self.fPortValues[index]
  166. if isOutput:
  167. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  168. else:
  169. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue('%s', %f, null)" % (symbol, value))
  170. # final setup
  171. self.fCanSetValues = True
  172. self.fSizeSetup = True
  173. self.fDocElemement = None
  174. if self.fNeedsShow:
  175. self.show()
  176. def checkForRepaintChanges(self):
  177. if not self.fWasRepainted:
  178. return
  179. self.fWasRepainted = False
  180. if not self.fCanSetValues:
  181. return
  182. for index in self.fPortValues.keys():
  183. symbol, isOutput = self.fPortSymbols[index]
  184. if isOutput:
  185. continue
  186. oldValue = self.fPortValues[index]
  187. newValue = self.fCurrentFrame.evaluateJavaScript("icongui.getPortValue('%s')" % (symbol,))
  188. if oldValue != newValue:
  189. self.fPortValues[index] = newValue
  190. self.send(["control", index, newValue])
  191. # --------------------------------------------------------------------------------------------------------
  192. @pyqtSlot(bool)
  193. def slot_webviewLoadFinished(self, ok):
  194. page = self.fWebview.page()
  195. page.repaintRequested.connect(self.slot_repaintRequested)
  196. self.fCurrentFrame = page.currentFrame()
  197. self.fDocElemement = self.fCurrentFrame.documentElement()
  198. def slot_repaintRequested(self):
  199. if self.fCanSetValues:
  200. self.fWasRepainted = True
  201. # --------------------------------------------------------------------------------------------------------
  202. # Callback
  203. def msgCallback(self, msg):
  204. msg = charPtrToString(msg)
  205. if msg == "control":
  206. index = self.readlineblock_int()
  207. value = self.readlineblock_float()
  208. self.dspParameterChanged(index, value)
  209. elif msg == "program":
  210. index = self.readlineblock_int()
  211. self.dspProgramChanged(index)
  212. elif msg == "midiprogram":
  213. bank = self.readlineblock_int()
  214. program = self.readlineblock_float()
  215. self.dspMidiProgramChanged(bank, program)
  216. elif msg == "configure":
  217. key = self.readlineblock()
  218. value = self.readlineblock()
  219. self.dspStateChanged(key, value)
  220. elif msg == "note":
  221. onOff = self.readlineblock_bool()
  222. channel = self.readlineblock_int()
  223. note = self.readlineblock_int()
  224. velocity = self.readlineblock_int()
  225. self.dspNoteReceived(onOff, channel, note, velocity)
  226. elif msg == "atom":
  227. index = self.readlineblock_int()
  228. size = self.readlineblock_int()
  229. base64atom = self.readlineblock()
  230. # nothing to do yet
  231. elif msg == "urid":
  232. urid = self.readlineblock_int()
  233. uri = self.readlineblock()
  234. # nothing to do yet
  235. elif msg == "uiOptions":
  236. sampleRate = self.readlineblock_float()
  237. bgColor = self.readlineblock_int()
  238. fgColor = self.readlineblock_int()
  239. uiScale = self.readlineblock_float()
  240. useTheme = self.readlineblock_bool()
  241. useThemeColors = self.readlineblock_bool()
  242. windowTitle = self.readlineblock()
  243. transWindowId = self.readlineblock_int()
  244. self.uiTitleChanged(windowTitle)
  245. elif msg == "show":
  246. self.uiShow()
  247. elif msg == "focus":
  248. self.uiFocus()
  249. elif msg == "hide":
  250. self.uiHide()
  251. elif msg == "quit":
  252. self.fQuitReceived = True
  253. self.uiQuit()
  254. elif msg == "uiTitle":
  255. uiTitle = self.readlineblock()
  256. self.uiTitleChanged(uiTitle)
  257. else:
  258. print("unknown message: \"" + msg + "\"")
  259. # --------------------------------------------------------------------------------------------------------
  260. def dspParameterChanged(self, index, value):
  261. self.fPortValues[index] = value
  262. if self.fCurrentFrame is not None and self.fCanSetValues:
  263. symbol, isOutput = self.fPortSymbols[index]
  264. if isOutput:
  265. self.fPortValues[index] = value
  266. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  267. else:
  268. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue('%s', %f, null)" % (symbol, value))
  269. def dspProgramChanged(self, index):
  270. return
  271. def dspMidiProgramChanged(self, bank, program):
  272. return
  273. def dspStateChanged(self, key, value):
  274. return
  275. def dspNoteReceived(self, onOff, channel, note, velocity):
  276. return
  277. # --------------------------------------------------------------------------------------------------------
  278. def uiShow(self):
  279. if self.fSizeSetup:
  280. self.show()
  281. else:
  282. self.fNeedsShow = True
  283. def uiFocus(self):
  284. if not self.fSizeSetup:
  285. return
  286. self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
  287. self.show()
  288. self.raise_()
  289. self.activateWindow()
  290. def uiHide(self):
  291. self.hide()
  292. def uiQuit(self):
  293. self.closeExternalUI()
  294. self.close()
  295. QApplication.instance().quit()
  296. def uiTitleChanged(self, uiTitle):
  297. self.setWindowTitle(uiTitle)
  298. # --------------------------------------------------------------------------------------------------------
  299. # Qt events
  300. def closeEvent(self, event):
  301. self.closeExternalUI()
  302. QMainWindow.closeEvent(self, event)
  303. # there might be other qt windows open which will block carla-modgui from quitting
  304. QApplication.instance().quit()
  305. def timerEvent(self, event):
  306. if event.timerId() == self.fIdleTimer:
  307. self.idleStuff()
  308. QMainWindow.timerEvent(self, event)
  309. # --------------------------------------------------------------------------------------------------------
  310. @pyqtSlot()
  311. def slot_handleSIGTERM(self):
  312. print("Got SIGTERM -> Closing now")
  313. self.close()
  314. # --------------------------------------------------------------------------------------------------------
  315. # Internal stuff
  316. def readlineblock(self):
  317. if self.fPipeClient is None:
  318. return ""
  319. return gCarla.utils.pipe_client_readlineblock(self.fPipeClient, 5000)
  320. def readlineblock_bool(self):
  321. if self.fPipeClient is None:
  322. return False
  323. return gCarla.utils.pipe_client_readlineblock_bool(self.fPipeClient, 5000)
  324. def readlineblock_int(self):
  325. if self.fPipeClient is None:
  326. return 0
  327. return gCarla.utils.pipe_client_readlineblock_int(self.fPipeClient, 5000)
  328. def readlineblock_float(self):
  329. if self.fPipeClient is None:
  330. return 0.0
  331. return gCarla.utils.pipe_client_readlineblock_float(self.fPipeClient, 5000)
  332. def send(self, lines):
  333. if self.fPipeClient is None or len(lines) == 0:
  334. return
  335. gCarla.utils.pipe_client_lock(self.fPipeClient)
  336. # this must never fail, we need to unlock at the end
  337. try:
  338. for line in lines:
  339. if line is None:
  340. line2 = "(null)"
  341. elif isinstance(line, str):
  342. line2 = line.replace("\n", "\r")
  343. elif isinstance(line, bool):
  344. line2 = "true" if line else "false"
  345. elif isinstance(line, int):
  346. line2 = "%i" % line
  347. elif isinstance(line, float):
  348. line2 = "%.10f" % line
  349. else:
  350. print("unknown data type to send:", type(line))
  351. return
  352. gCarla.utils.pipe_client_write_msg(self.fPipeClient, line2 + "\n")
  353. except:
  354. pass
  355. gCarla.utils.pipe_client_flush_and_unlock(self.fPipeClient)