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.

670 lines
21KB

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