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.

547 lines
18KB

  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. self.fParamTypes = {}
  58. self.fParamValues = {}
  59. for port in self.fPorts['control']['input']:
  60. self.fPortSymbols[port['index']] = (port['symbol'], False)
  61. self.fPortValues [port['index']] = port['ranges']['default']
  62. for port in self.fPorts['control']['output']:
  63. self.fPortSymbols[port['index']] = (port['symbol'], True)
  64. self.fPortValues [port['index']] = port['ranges']['default']
  65. for parameter in self.fPlugin['parameters']:
  66. if parameter['ranges'] is None:
  67. continue
  68. if parameter['type'] == "http://lv2plug.in/ns/ext/atom#Bool":
  69. paramtype = 'b'
  70. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#Int":
  71. paramtype = 'i'
  72. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#Long":
  73. paramtype = 'l'
  74. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#Float":
  75. paramtype = 'f'
  76. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#Double":
  77. paramtype = 'g'
  78. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#String":
  79. paramtype = 's'
  80. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#Path":
  81. paramtype = 'p'
  82. elif parameter['type'] == "http://lv2plug.in/ns/ext/atom#URI":
  83. paramtype = 'u'
  84. else:
  85. continue
  86. if paramtype not in ('s','p','u') and parameter['ranges']['minimum'] == parameter['ranges']['maximum']:
  87. continue
  88. self.fParamTypes [parameter['uri']] = paramtype
  89. self.fParamValues[parameter['uri']] = parameter['ranges']['default']
  90. # ----------------------------------------------------------------------------------------------------
  91. # Init pipe
  92. if len(sys.argv) == 7:
  93. self.fPipeClient = gCarla.utils.pipe_client_new(lambda s,msg: self.msgCallback(msg))
  94. else:
  95. self.fPipeClient = None
  96. # ----------------------------------------------------------------------------------------------------
  97. # Init Web server
  98. self.fWebServerThread = WebServerThread(self)
  99. self.fWebServerThread.start()
  100. # ----------------------------------------------------------------------------------------------------
  101. # Set up GUI
  102. self.setContentsMargins(0, 0, 0, 0)
  103. self.fWebview = QWebView(self)
  104. #self.fWebview.setAttribute(Qt.WA_OpaquePaintEvent, False)
  105. #self.fWebview.setAttribute(Qt.WA_TranslucentBackground, True)
  106. self.setCentralWidget(self.fWebview)
  107. page = self.fWebview.page()
  108. page.setViewportSize(QSize(980, 600))
  109. mainFrame = page.mainFrame()
  110. mainFrame.setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
  111. mainFrame.setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
  112. palette = self.fWebview.palette()
  113. palette.setBrush(QPalette.Base, palette.brush(QPalette.Window))
  114. page.setPalette(palette)
  115. self.fWebview.setPalette(palette)
  116. settings = self.fWebview.settings()
  117. settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
  118. self.fWebview.loadFinished.connect(self.slot_webviewLoadFinished)
  119. url = "http://127.0.0.1:%s/icon.html#%s" % (PORT, URI)
  120. print("url:", url)
  121. self.fWebview.load(QUrl(url))
  122. # ----------------------------------------------------------------------------------------------------
  123. # Connect actions to functions
  124. self.SIGTERM.connect(self.slot_handleSIGTERM)
  125. # ----------------------------------------------------------------------------------------------------
  126. # Final setup
  127. self.fIdleTimer = self.startTimer(30)
  128. if self.fPipeClient is None:
  129. # testing, show UI only
  130. self.setWindowTitle("TestUI")
  131. self.fNeedsShow = True
  132. # --------------------------------------------------------------------------------------------------------
  133. def closeExternalUI(self):
  134. self.fWebServerThread.stopWait()
  135. if self.fPipeClient is None:
  136. return
  137. if not self.fQuitReceived:
  138. self.send(["exiting"])
  139. gCarla.utils.pipe_client_destroy(self.fPipeClient)
  140. self.fPipeClient = None
  141. def idleStuff(self):
  142. if self.fPipeClient is not None:
  143. gCarla.utils.pipe_client_idle(self.fPipeClient)
  144. self.checkForRepaintChanges()
  145. if self.fSizeSetup:
  146. return
  147. if self.fDocElemement is None or self.fDocElemement.isNull():
  148. return
  149. pedal = self.fDocElemement.findFirst(".mod-pedal")
  150. if pedal.isNull():
  151. return
  152. size = pedal.geometry().size()
  153. if size.width() <= 10 or size.height() <= 10:
  154. return
  155. # render web frame to image
  156. image = QImage(self.fWebview.page().viewportSize(), QImage.Format_ARGB32_Premultiplied)
  157. image.fill(Qt.transparent)
  158. painter = QPainter(image)
  159. self.fCurrentFrame.render(painter)
  160. painter.end()
  161. #image.save("/tmp/test.png")
  162. # get coordinates and size from image
  163. #x = -1
  164. #y = -1
  165. #lastx = -1
  166. #lasty = -1
  167. #bgcol = self.fHostColor.rgba()
  168. #for h in range(0, image.height()):
  169. #hasNonTransPixels = False
  170. #for w in range(0, image.width()):
  171. #if image.pixel(w, h) not in (0, bgcol): # 0xff070707):
  172. #hasNonTransPixels = True
  173. #if x == -1 or x > w:
  174. #x = w
  175. #lastx = max(lastx, w)
  176. #if hasNonTransPixels:
  177. ##if y == -1:
  178. ##y = h
  179. #lasty = h
  180. # set size and position accordingly
  181. #if -1 not in (x, lastx, lasty):
  182. #self.setFixedSize(lastx-x, lasty)
  183. #self.fCurrentFrame.setScrollPosition(QPoint(x, 0))
  184. #else:
  185. # TODO that^ needs work
  186. if True:
  187. self.setFixedSize(size)
  188. # set initial values
  189. self.fCurrentFrame.evaluateJavaScript("icongui.setPortWidgetsValue(':bypass', 0, null)")
  190. for index in self.fPortValues.keys():
  191. symbol, isOutput = self.fPortSymbols[index]
  192. value = self.fPortValues[index]
  193. if isOutput:
  194. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  195. else:
  196. self.fCurrentFrame.evaluateJavaScript("icongui.setPortWidgetsValue('%s', %f, null)" % (symbol, value))
  197. for uri in self.fParamValues.keys():
  198. ptype = self.fParamTypes[uri]
  199. value = str(self.fParamValues[uri])
  200. print("icongui.setWritableParameterValue('%s', '%c', %s, 'from-carla')" % (uri, ptype, value))
  201. self.fCurrentFrame.evaluateJavaScript("icongui.setWritableParameterValue('%s', '%c', %s, 'from-carla')" % (
  202. uri, ptype, value))
  203. # final setup
  204. self.fCanSetValues = True
  205. self.fSizeSetup = True
  206. self.fDocElemement = None
  207. if self.fNeedsShow:
  208. self.show()
  209. def checkForRepaintChanges(self):
  210. if not self.fWasRepainted:
  211. return
  212. self.fWasRepainted = False
  213. if not self.fCanSetValues:
  214. return
  215. for index in self.fPortValues.keys():
  216. symbol, isOutput = self.fPortSymbols[index]
  217. if isOutput:
  218. continue
  219. oldValue = self.fPortValues[index]
  220. newValue = self.fCurrentFrame.evaluateJavaScript("icongui.controls['%s'].value" % (symbol,))
  221. if oldValue != newValue:
  222. self.fPortValues[index] = newValue
  223. self.send(["control", index, newValue])
  224. for uri in self.fParamValues.keys():
  225. oldValue = self.fParamValues[uri]
  226. newValue = self.fCurrentFrame.evaluateJavaScript("icongui.parameters['%s'].value" % (uri,))
  227. if oldValue != newValue:
  228. self.fParamValues[uri] = newValue
  229. self.send(["pcontrol", uri, newValue])
  230. # --------------------------------------------------------------------------------------------------------
  231. @pyqtSlot(bool)
  232. def slot_webviewLoadFinished(self, ok):
  233. page = self.fWebview.page()
  234. page.repaintRequested.connect(self.slot_repaintRequested)
  235. self.fCurrentFrame = page.currentFrame()
  236. self.fDocElemement = self.fCurrentFrame.documentElement()
  237. def slot_repaintRequested(self):
  238. if self.fCanSetValues:
  239. self.fWasRepainted = True
  240. # --------------------------------------------------------------------------------------------------------
  241. # Callback
  242. def msgCallback(self, msg):
  243. msg = charPtrToString(msg)
  244. if msg == "control":
  245. index = self.readlineblock_int()
  246. value = self.readlineblock_float()
  247. self.dspControlChanged(index, value)
  248. elif msg == "parameter":
  249. uri = self.readlineblock()
  250. value = self.readlineblock_float()
  251. self.dspParameterChanged(uri, value)
  252. elif msg == "program":
  253. index = self.readlineblock_int()
  254. self.dspProgramChanged(index)
  255. elif msg == "midiprogram":
  256. bank = self.readlineblock_int()
  257. program = self.readlineblock_int()
  258. self.dspMidiProgramChanged(bank, program)
  259. elif msg == "configure":
  260. key = self.readlineblock()
  261. value = self.readlineblock()
  262. self.dspStateChanged(key, value)
  263. elif msg == "note":
  264. onOff = self.readlineblock_bool()
  265. channel = self.readlineblock_int()
  266. note = self.readlineblock_int()
  267. velocity = self.readlineblock_int()
  268. self.dspNoteReceived(onOff, channel, note, velocity)
  269. elif msg == "atom":
  270. index = self.readlineblock_int()
  271. atomsize = self.readlineblock_int()
  272. base64size = self.readlineblock_int()
  273. base64atom = self.readlineblock()
  274. # nothing to do yet
  275. elif msg == "urid":
  276. urid = self.readlineblock_int()
  277. size = self.readlineblock_int()
  278. uri = self.readlineblock()
  279. # nothing to do yet
  280. elif msg == "uiOptions":
  281. sampleRate = self.readlineblock_float()
  282. bgColor = self.readlineblock_int()
  283. fgColor = self.readlineblock_int()
  284. uiScale = self.readlineblock_float()
  285. useTheme = self.readlineblock_bool()
  286. useThemeColors = self.readlineblock_bool()
  287. windowTitle = self.readlineblock()
  288. transWindowId = self.readlineblock_int()
  289. self.uiTitleChanged(windowTitle)
  290. elif msg == "show":
  291. self.uiShow()
  292. elif msg == "focus":
  293. self.uiFocus()
  294. elif msg == "hide":
  295. self.uiHide()
  296. elif msg == "quit":
  297. self.fQuitReceived = True
  298. self.uiQuit()
  299. elif msg == "uiTitle":
  300. uiTitle = self.readlineblock()
  301. self.uiTitleChanged(uiTitle)
  302. else:
  303. print("unknown message: \"" + msg + "\"")
  304. # --------------------------------------------------------------------------------------------------------
  305. def dspControlChanged(self, index, value):
  306. self.fPortValues[index] = value
  307. if self.fCurrentFrame is None or not self.fCanSetValues:
  308. return
  309. symbol, isOutput = self.fPortSymbols[index]
  310. if isOutput:
  311. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  312. else:
  313. self.fCurrentFrame.evaluateJavaScript("icongui.setPortWidgetsValue('%s', %f, null)" % (symbol, value))
  314. def dspParameterChanged(self, uri, value):
  315. print("dspParameterChanged", uri, value)
  316. if uri not in self.fParamValues:
  317. return
  318. self.fParamValues[uri] = value
  319. if self.fCurrentFrame is None or not self.fCanSetValues:
  320. return
  321. ptype = self.fParamTypes[uri]
  322. self.fCurrentFrame.evaluateJavaScript("icongui.setWritableParameterValue('%s', '%c', %f, 'from-carla')" % (
  323. uri, ptype, value))
  324. def dspProgramChanged(self, index):
  325. return
  326. def dspMidiProgramChanged(self, bank, program):
  327. return
  328. def dspStateChanged(self, key, value):
  329. return
  330. def dspNoteReceived(self, onOff, channel, note, velocity):
  331. return
  332. # --------------------------------------------------------------------------------------------------------
  333. def uiShow(self):
  334. if self.fSizeSetup:
  335. self.show()
  336. else:
  337. self.fNeedsShow = True
  338. def uiFocus(self):
  339. if not self.fSizeSetup:
  340. return
  341. self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
  342. self.show()
  343. self.raise_()
  344. self.activateWindow()
  345. def uiHide(self):
  346. self.hide()
  347. def uiQuit(self):
  348. self.closeExternalUI()
  349. self.close()
  350. QApplication.instance().quit()
  351. def uiTitleChanged(self, uiTitle):
  352. self.setWindowTitle(uiTitle)
  353. # --------------------------------------------------------------------------------------------------------
  354. # Qt events
  355. def closeEvent(self, event):
  356. self.closeExternalUI()
  357. QMainWindow.closeEvent(self, event)
  358. # there might be other qt windows open which will block carla-modgui from quitting
  359. QApplication.instance().quit()
  360. def timerEvent(self, event):
  361. if event.timerId() == self.fIdleTimer:
  362. self.idleStuff()
  363. QMainWindow.timerEvent(self, event)
  364. # --------------------------------------------------------------------------------------------------------
  365. @pyqtSlot()
  366. def slot_handleSIGTERM(self):
  367. print("Got SIGTERM -> Closing now")
  368. self.close()
  369. # --------------------------------------------------------------------------------------------------------
  370. # Internal stuff
  371. def readlineblock(self):
  372. if self.fPipeClient is None:
  373. return ""
  374. return gCarla.utils.pipe_client_readlineblock(self.fPipeClient, 5000)
  375. def readlineblock_bool(self):
  376. if self.fPipeClient is None:
  377. return False
  378. return gCarla.utils.pipe_client_readlineblock_bool(self.fPipeClient, 5000)
  379. def readlineblock_int(self):
  380. if self.fPipeClient is None:
  381. return 0
  382. return gCarla.utils.pipe_client_readlineblock_int(self.fPipeClient, 5000)
  383. def readlineblock_float(self):
  384. if self.fPipeClient is None:
  385. return 0.0
  386. return gCarla.utils.pipe_client_readlineblock_float(self.fPipeClient, 5000)
  387. def send(self, lines):
  388. if self.fPipeClient is None or len(lines) == 0:
  389. return
  390. gCarla.utils.pipe_client_lock(self.fPipeClient)
  391. # this must never fail, we need to unlock at the end
  392. try:
  393. for line in lines:
  394. if line is None:
  395. line2 = "(null)"
  396. elif isinstance(line, str):
  397. line2 = line.replace("\n", "\r")
  398. elif isinstance(line, bool):
  399. line2 = "true" if line else "false"
  400. elif isinstance(line, int):
  401. line2 = "%i" % line
  402. elif isinstance(line, float):
  403. line2 = "%.10f" % line
  404. else:
  405. print("unknown data type to send:", type(line))
  406. return
  407. gCarla.utils.pipe_client_write_msg(self.fPipeClient, line2 + "\n")
  408. except:
  409. pass
  410. gCarla.utils.pipe_client_flush_and_unlock(self.fPipeClient)