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.

carla_modgui.py 22KB

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