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.

693 lines
22KB

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