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.

701 lines
22KB

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. # Carla bridge for LV2 modguis
  4. # Copyright (C) 2015-2016 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 (Config)
  19. from carla_config import *
  20. # ------------------------------------------------------------------------------------------------------------
  21. # Imports (Global)
  22. import json
  23. if config_UseQt5:
  24. from PyQt5.QtCore import pyqtSlot, QPoint, QThread, QSize, QUrl
  25. from PyQt5.QtGui import QImage, QPainter, QPalette
  26. from PyQt5.QtWidgets import QMainWindow
  27. from PyQt5.QtWebKit import QWebElement, QWebSettings
  28. from PyQt5.QtWebKitWidgets import QWebView
  29. else:
  30. from PyQt4.QtCore import pyqtSlot, QPoint, QThread, QSize, QUrl
  31. from PyQt4.QtGui import QImage, QPainter, QPalette
  32. from PyQt4.QtGui import QMainWindow
  33. from PyQt4.QtWebKit import QWebElement, QWebSettings, QWebView
  34. # ------------------------------------------------------------------------------------------------------------
  35. # Imports (tornado)
  36. from tornado.log import enable_pretty_logging
  37. from tornado.ioloop import IOLoop
  38. from tornado.util import unicode_type
  39. from tornado.web import HTTPError
  40. from tornado.web import Application, RequestHandler, StaticFileHandler
  41. # ------------------------------------------------------------------------------------------------------------
  42. # Imports (Custom)
  43. from carla_app import *
  44. from carla_utils import *
  45. # ------------------------------------------------------------------------------------------------------------
  46. # Generate a random port number between 9000 and 18000
  47. from random import random
  48. PORTn = 8998 + int(random()*9000)
  49. # ------------------------------------------------------------------------------------------------------------
  50. # Set up environment for the webserver
  51. PORT = str(PORTn)
  52. ROOT = "/usr/share/mod"
  53. #ROOT = "/home/falktx/FOSS/GIT-mine/MOD/mod-app/source/modules/mod-ui"
  54. DATA_DIR = os.path.expanduser("~/.local/share/mod-data/")
  55. HTML_DIR = os.path.join(ROOT, "html")
  56. os.environ['MOD_DEV_HOST'] = "1"
  57. os.environ['MOD_DEV_HMI'] = "1"
  58. os.environ['MOD_DESKTOP'] = "1"
  59. os.environ['MOD_DATA_DIR'] = DATA_DIR
  60. os.environ['MOD_HTML_DIR'] = HTML_DIR
  61. os.environ['MOD_KEY_PATH'] = os.path.join(DATA_DIR, "keys")
  62. os.environ['MOD_CLOUD_PUB'] = os.path.join(ROOT, "keys", "cloud_key.pub")
  63. os.environ['MOD_PLUGIN_LIBRARY_DIR'] = os.path.join(DATA_DIR, "lib")
  64. os.environ['MOD_PHANTOM_BINARY'] = "/usr/bin/phantomjs"
  65. os.environ['MOD_SCREENSHOT_JS'] = os.path.join(ROOT, "screenshot.js")
  66. os.environ['MOD_DEVICE_WEBSERVER_PORT'] = PORT
  67. # ------------------------------------------------------------------------------------------------------------
  68. # Imports (MOD)
  69. from mod.utils import get_plugin_info, get_plugin_gui, get_plugin_gui_mini, init as lv2_init
  70. # ------------------------------------------------------------------------------------------------------------
  71. # MOD related classes
  72. class JsonRequestHandler(RequestHandler):
  73. def write(self, data):
  74. if isinstance(data, (bytes, unicode_type, dict)):
  75. RequestHandler.write(self, data)
  76. self.finish()
  77. return
  78. elif data is True:
  79. data = "true"
  80. self.set_header("Content-Type", "application/json; charset=UTF-8")
  81. elif data is False:
  82. data = "false"
  83. self.set_header("Content-Type", "application/json; charset=UTF-8")
  84. else:
  85. data = json.dumps(data)
  86. self.set_header("Content-Type", "application/json; charset=UTF-8")
  87. RequestHandler.write(self, data)
  88. self.finish()
  89. class EffectGet(JsonRequestHandler):
  90. def get(self):
  91. uri = self.get_argument('uri')
  92. try:
  93. data = get_plugin_info(uri)
  94. except:
  95. print("ERROR: get_plugin_info for '%s' failed" % uri)
  96. raise HTTPError(404)
  97. self.write(data)
  98. class EffectFile(StaticFileHandler):
  99. def initialize(self):
  100. # return custom type directly. The browser will do the parsing
  101. self.custom_type = None
  102. uri = self.get_argument('uri')
  103. try:
  104. self.modgui = get_plugin_gui(uri)
  105. except:
  106. raise HTTPError(404)
  107. try:
  108. root = self.modgui['resourcesDirectory']
  109. except:
  110. raise HTTPError(404)
  111. return StaticFileHandler.initialize(self, root)
  112. def parse_url_path(self, prop):
  113. try:
  114. path = self.modgui[prop]
  115. except:
  116. raise HTTPError(404)
  117. if prop in ("iconTemplate", "settingsTemplate", "stylesheet", "javascript"):
  118. self.custom_type = "text/plain"
  119. return path
  120. def get_content_type(self):
  121. if self.custom_type is not None:
  122. return self.custom_type
  123. return StaticFileHandler.get_content_type(self)
  124. class EffectResource(StaticFileHandler):
  125. def initialize(self):
  126. # Overrides StaticFileHandler initialize
  127. pass
  128. def get(self, path):
  129. try:
  130. uri = self.get_argument('uri')
  131. except:
  132. return self.shared_resource(path)
  133. try:
  134. modgui = get_plugin_gui_mini(uri)
  135. except:
  136. raise HTTPError(404)
  137. try:
  138. root = modgui['resourcesDirectory']
  139. except:
  140. raise HTTPError(404)
  141. try:
  142. super(EffectResource, self).initialize(root)
  143. return super(EffectResource, self).get(path)
  144. except HTTPError as e:
  145. if e.status_code != 404:
  146. raise e
  147. return self.shared_resource(path)
  148. except IOError:
  149. raise HTTPError(404)
  150. def shared_resource(self, path):
  151. super(EffectResource, self).initialize(os.path.join(HTML_DIR, 'resources'))
  152. return super(EffectResource, self).get(path)
  153. # ------------------------------------------------------------------------------------------------------------
  154. # WebServer Thread
  155. class WebServerThread(QThread):
  156. # signals
  157. running = pyqtSignal()
  158. def __init__(self, parent=None):
  159. QThread.__init__(self, parent)
  160. self.fApplication = Application(
  161. [
  162. (r"/effect/get/?", EffectGet),
  163. (r"/effect/file/(.*)", EffectFile),
  164. (r"/resources/(.*)", EffectResource),
  165. (r"/(.*)", StaticFileHandler, {"path": HTML_DIR}),
  166. ],
  167. debug=True)
  168. self.fPrepareWasCalled = False
  169. def run(self):
  170. if not self.fPrepareWasCalled:
  171. self.fPrepareWasCalled = True
  172. self.fApplication.listen(PORT, address="0.0.0.0")
  173. if int(os.getenv("MOD_LOG", "0")):
  174. enable_pretty_logging()
  175. self.running.emit()
  176. IOLoop.instance().start()
  177. def stopWait(self):
  178. IOLoop.instance().stop()
  179. return self.wait(5000)
  180. # ------------------------------------------------------------------------------------------------------------
  181. # Host Window
  182. class HostWindow(QMainWindow):
  183. # signals
  184. SIGTERM = pyqtSignal()
  185. SIGUSR1 = pyqtSignal()
  186. # --------------------------------------------------------------------------------------------------------
  187. def __init__(self):
  188. QMainWindow.__init__(self)
  189. gCarla.gui = self
  190. URI = sys.argv[1]
  191. # ----------------------------------------------------------------------------------------------------
  192. # Internal stuff
  193. self.fCurrentFrame = None
  194. self.fDocElemement = None
  195. self.fCanSetValues = False
  196. self.fNeedsShow = False
  197. self.fSizeSetup = False
  198. self.fQuitReceived = False
  199. self.fWasRepainted = False
  200. self.fPlugin = get_plugin_info(URI)
  201. self.fPorts = self.fPlugin['ports']
  202. self.fPortSymbols = {}
  203. self.fPortValues = {}
  204. for port in self.fPorts['control']['input']:
  205. self.fPortSymbols[port['index']] = (port['symbol'], False)
  206. self.fPortValues [port['index']] = port['ranges']['default']
  207. for port in self.fPorts['control']['output']:
  208. self.fPortSymbols[port['index']] = (port['symbol'], True)
  209. self.fPortValues [port['index']] = port['ranges']['default']
  210. # ----------------------------------------------------------------------------------------------------
  211. # Init pipe
  212. if len(sys.argv) == 7:
  213. self.fPipeClient = gCarla.utils.pipe_client_new(lambda s,msg: self.msgCallback(msg))
  214. else:
  215. self.fPipeClient = None
  216. # ----------------------------------------------------------------------------------------------------
  217. # Init Web server
  218. self.fWebServerThread = WebServerThread(self)
  219. self.fWebServerThread.start()
  220. # ----------------------------------------------------------------------------------------------------
  221. # Set up GUI
  222. self.setContentsMargins(0, 0, 0, 0)
  223. self.fWebview = QWebView(self)
  224. #self.fWebview.setAttribute(Qt.WA_OpaquePaintEvent, False)
  225. #self.fWebview.setAttribute(Qt.WA_TranslucentBackground, True)
  226. self.setCentralWidget(self.fWebview)
  227. page = self.fWebview.page()
  228. page.setViewportSize(QSize(980, 600))
  229. mainFrame = page.mainFrame()
  230. mainFrame.setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
  231. mainFrame.setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
  232. palette = self.fWebview.palette()
  233. palette.setBrush(QPalette.Base, palette.brush(QPalette.Window))
  234. page.setPalette(palette)
  235. self.fWebview.setPalette(palette)
  236. settings = self.fWebview.settings()
  237. settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
  238. self.fWebview.loadFinished.connect(self.slot_webviewLoadFinished)
  239. url = "http://127.0.0.1:%s/icon.html#%s" % (PORT, URI)
  240. print("url:", url)
  241. self.fWebview.load(QUrl(url))
  242. # ----------------------------------------------------------------------------------------------------
  243. # Connect actions to functions
  244. self.SIGTERM.connect(self.slot_handleSIGTERM)
  245. # ----------------------------------------------------------------------------------------------------
  246. # Final setup
  247. self.fIdleTimer = self.startTimer(30)
  248. if self.fPipeClient is None:
  249. # testing, show UI only
  250. self.setWindowTitle("TestUI")
  251. self.fNeedsShow = True
  252. # --------------------------------------------------------------------------------------------------------
  253. def closeExternalUI(self):
  254. self.fWebServerThread.stopWait()
  255. if self.fPipeClient is None:
  256. return
  257. if not self.fQuitReceived:
  258. self.send(["exiting"])
  259. gCarla.utils.pipe_client_destroy(self.fPipeClient)
  260. self.fPipeClient = None
  261. def idleStuff(self):
  262. if self.fPipeClient is not None:
  263. gCarla.utils.pipe_client_idle(self.fPipeClient)
  264. self.checkForRepaintChanges()
  265. if self.fSizeSetup:
  266. return
  267. if self.fDocElemement is None or self.fDocElemement.isNull():
  268. return
  269. pedal = self.fDocElemement.findFirst(".mod-pedal")
  270. if pedal.isNull():
  271. return
  272. size = pedal.geometry().size()
  273. if size.width() <= 10 or size.height() <= 10:
  274. return
  275. # render web frame to image
  276. image = QImage(self.fWebview.page().viewportSize(), QImage.Format_ARGB32_Premultiplied)
  277. image.fill(Qt.transparent)
  278. painter = QPainter(image)
  279. self.fCurrentFrame.render(painter)
  280. painter.end()
  281. #image.save("/tmp/test.png")
  282. # get coordinates and size from image
  283. #x = -1
  284. #y = -1
  285. #lastx = -1
  286. #lasty = -1
  287. #bgcol = self.fHostColor.rgba()
  288. #for h in range(0, image.height()):
  289. #hasNonTransPixels = False
  290. #for w in range(0, image.width()):
  291. #if image.pixel(w, h) not in (0, bgcol): # 0xff070707):
  292. #hasNonTransPixels = True
  293. #if x == -1 or x > w:
  294. #x = w
  295. #lastx = max(lastx, w)
  296. #if hasNonTransPixels:
  297. ##if y == -1:
  298. ##y = h
  299. #lasty = h
  300. # set size and position accordingly
  301. #if -1 not in (x, lastx, lasty):
  302. #self.setFixedSize(lastx-x, lasty)
  303. #self.fCurrentFrame.setScrollPosition(QPoint(x, 0))
  304. #else:
  305. # TODO that^ needs work
  306. if True:
  307. self.setFixedSize(size)
  308. # set initial values
  309. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue(':bypass', 0, null)")
  310. for index in self.fPortValues.keys():
  311. symbol, isOutput = self.fPortSymbols[index]
  312. value = self.fPortValues[index]
  313. if isOutput:
  314. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  315. else:
  316. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue('%s', %f, null)" % (symbol, value))
  317. # final setup
  318. self.fCanSetValues = True
  319. self.fSizeSetup = True
  320. self.fDocElemement = None
  321. if self.fNeedsShow:
  322. self.show()
  323. def checkForRepaintChanges(self):
  324. if not self.fWasRepainted:
  325. return
  326. self.fWasRepainted = False
  327. if not self.fCanSetValues:
  328. return
  329. for index in self.fPortValues.keys():
  330. symbol, isOutput = self.fPortSymbols[index]
  331. if isOutput:
  332. continue
  333. oldValue = self.fPortValues[index]
  334. newValue = self.fCurrentFrame.evaluateJavaScript("icongui.getPortValue('%s')" % (symbol,))
  335. if oldValue != newValue:
  336. self.fPortValues[index] = newValue
  337. self.send(["control", index, newValue])
  338. # --------------------------------------------------------------------------------------------------------
  339. @pyqtSlot(bool)
  340. def slot_webviewLoadFinished(self, ok):
  341. page = self.fWebview.page()
  342. page.repaintRequested.connect(self.slot_repaintRequested)
  343. self.fCurrentFrame = page.currentFrame()
  344. self.fDocElemement = self.fCurrentFrame.documentElement()
  345. def slot_repaintRequested(self):
  346. if self.fCanSetValues:
  347. self.fWasRepainted = True
  348. # --------------------------------------------------------------------------------------------------------
  349. # Callback
  350. def msgCallback(self, msg):
  351. msg = charPtrToString(msg)
  352. if msg == "control":
  353. index = int(self.readlineblock())
  354. value = float(self.readlineblock())
  355. self.dspParameterChanged(index, value)
  356. elif msg == "program":
  357. index = int(self.readlineblock())
  358. self.dspProgramChanged(index)
  359. elif msg == "midiprogram":
  360. bank = int(self.readlineblock())
  361. program = float(self.readlineblock())
  362. self.dspMidiProgramChanged(bank, program)
  363. elif msg == "configure":
  364. key = self.readlineblock()
  365. value = self.readlineblock()
  366. self.dspStateChanged(key, value)
  367. elif msg == "note":
  368. onOff = bool(self.readlineblock() == "true")
  369. channel = int(self.readlineblock())
  370. note = int(self.readlineblock())
  371. velocity = int(self.readlineblock())
  372. self.dspNoteReceived(onOff, channel, note, velocity)
  373. elif msg == "atom":
  374. index = int(self.readlineblock())
  375. size = int(self.readlineblock())
  376. base64atom = self.readlineblock()
  377. # nothing to do yet
  378. elif msg == "urid":
  379. urid = int(self.readlineblock())
  380. uri = self.readlineblock()
  381. # nothing to do yet
  382. elif msg == "uiOptions":
  383. sampleRate = float(self.readlineblock())
  384. useTheme = bool(self.readlineblock() == "true")
  385. useThemeColors = bool(self.readlineblock() == "true")
  386. windowTitle = self.readlineblock()
  387. transWindowId = int(self.readlineblock())
  388. self.uiTitleChanged(windowTitle)
  389. elif msg == "show":
  390. self.uiShow()
  391. elif msg == "focus":
  392. self.uiFocus()
  393. elif msg == "hide":
  394. self.uiHide()
  395. elif msg == "quit":
  396. self.fQuitReceived = True
  397. self.uiQuit()
  398. elif msg == "uiTitle":
  399. uiTitle = self.readlineblock()
  400. self.uiTitleChanged(uiTitle)
  401. else:
  402. print("unknown message: \"" + msg + "\"")
  403. # --------------------------------------------------------------------------------------------------------
  404. def dspParameterChanged(self, index, value):
  405. self.fPortValues[index] = value
  406. if self.fCurrentFrame is not None and self.fCanSetValues:
  407. symbol, isOutput = self.fPortSymbols[index]
  408. if isOutput:
  409. self.fPortValues[index] = value
  410. self.fCurrentFrame.evaluateJavaScript("icongui.setOutputPortValue('%s', %f)" % (symbol, value))
  411. else:
  412. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue('%s', %f, null)" % (symbol, value))
  413. def dspProgramChanged(self, index):
  414. return
  415. def dspMidiProgramChanged(self, bank, program):
  416. return
  417. def dspStateChanged(self, key, value):
  418. return
  419. def dspNoteReceived(self, onOff, channel, note, velocity):
  420. return
  421. # --------------------------------------------------------------------------------------------------------
  422. def uiShow(self):
  423. if self.fSizeSetup:
  424. self.show()
  425. else:
  426. self.fNeedsShow = True
  427. def uiFocus(self):
  428. if not self.fSizeSetup:
  429. return
  430. self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
  431. self.show()
  432. self.raise_()
  433. self.activateWindow()
  434. def uiHide(self):
  435. self.hide()
  436. def uiQuit(self):
  437. self.closeExternalUI()
  438. self.close()
  439. app.quit()
  440. def uiTitleChanged(self, uiTitle):
  441. self.setWindowTitle(uiTitle)
  442. # --------------------------------------------------------------------------------------------------------
  443. # Qt events
  444. def closeEvent(self, event):
  445. self.closeExternalUI()
  446. QMainWindow.closeEvent(self, event)
  447. # there might be other qt windows open which will block carla-modgui from quitting
  448. app.quit()
  449. def timerEvent(self, event):
  450. if event.timerId() == self.fIdleTimer:
  451. self.idleStuff()
  452. QMainWindow.timerEvent(self, event)
  453. # --------------------------------------------------------------------------------------------------------
  454. @pyqtSlot()
  455. def slot_handleSIGTERM(self):
  456. print("Got SIGTERM -> Closing now")
  457. self.close()
  458. # --------------------------------------------------------------------------------------------------------
  459. # Internal stuff
  460. def readlineblock(self):
  461. if self.fPipeClient is None:
  462. return ""
  463. return gCarla.utils.pipe_client_readlineblock(self.fPipeClient, 5000)
  464. def send(self, lines):
  465. if self.fPipeClient is None or len(lines) == 0:
  466. return
  467. gCarla.utils.pipe_client_lock(self.fPipeClient)
  468. # this must never fail, we need to unlock at the end
  469. try:
  470. for line in lines:
  471. if line is None:
  472. line2 = "(null)"
  473. elif isinstance(line, str):
  474. line2 = line.replace("\n", "\r")
  475. elif isinstance(line, bool):
  476. line2 = "true" if line else "false"
  477. elif isinstance(line, int):
  478. line2 = "%i" % line
  479. elif isinstance(line, float):
  480. line2 = "%.10f" % line
  481. else:
  482. print("unknown data type to send:", type(line))
  483. return
  484. gCarla.utils.pipe_client_write_msg(self.fPipeClient, line2 + "\n")
  485. except:
  486. pass
  487. gCarla.utils.pipe_client_flush_and_unlock(self.fPipeClient)
  488. # ------------------------------------------------------------------------------------------------------------
  489. # Main
  490. if __name__ == '__main__':
  491. # -------------------------------------------------------------
  492. # Read CLI args
  493. if len(sys.argv) < 2:
  494. print("usage: %s <plugin-uri>" % sys.argv[0])
  495. sys.exit(1)
  496. libPrefix = os.getenv("CARLA_LIB_PREFIX")
  497. # -------------------------------------------------------------
  498. # App initialization
  499. app = CarlaApplication("Carla2-MODGUI", libPrefix)
  500. # -------------------------------------------------------------
  501. # Init utils
  502. pathBinaries, pathResources = getPaths(libPrefix)
  503. utilsname = "libcarla_utils.%s" % (DLL_EXTENSION)
  504. gCarla.utils = CarlaUtils(os.path.join(pathBinaries, utilsname))
  505. gCarla.utils.set_process_name("carla-bridge-lv2-modgui")
  506. # -------------------------------------------------------------
  507. # Set-up custom signal handling
  508. setUpSignals()
  509. # -------------------------------------------------------------
  510. # Init LV2
  511. lv2_init()
  512. # -------------------------------------------------------------
  513. # Create GUI
  514. gui = HostWindow()
  515. # --------------------------------------------------------------------------------------------------------
  516. # App-Loop
  517. app.exit_exec()
  518. # ------------------------------------------------------------------------------------------------------------