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.

689 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 PyQt4.QtGui import QImage, QPainter
  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
  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.ioloop import IOLoop
  38. from tornado.web import asynchronous, 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"
  52. #ROOT = "/home/falktx/FOSS/GIT-mine/MOD/mod-app/source/modules"
  53. DATA_DIR = os.path.expanduser("~/.local/share/mod-data/")
  54. HTML_DIR = os.path.join(ROOT, "mod-ui", "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, "mod-ui", "keys", "cloud_key.pub")
  63. os.environ['MOD_PLUGIN_LIBRARY_DIR'] = os.path.join(DATA_DIR, "lib")
  64. os.environ['MOD_DEFAULT_JACK_BUFSIZE'] = "0"
  65. os.environ['MOD_PHANTOM_BINARY'] = "/usr/bin/phantomjs"
  66. os.environ['MOD_SCREENSHOT_JS'] = os.path.join(ROOT, "mod-ui", "screenshot.js")
  67. os.environ['MOD_DEVICE_WEBSERVER_PORT'] = PORT
  68. # ------------------------------------------------------------------------------------------------------------
  69. # Imports (MOD)
  70. from mod.indexing import EffectIndex
  71. from mod.lv2 import PluginSerializer
  72. # ------------------------------------------------------------------------------------------------------------
  73. # MOD related classes
  74. class EffectSearcher(RequestHandler):
  75. index = EffectIndex()
  76. @classmethod
  77. def urls(cls, path):
  78. return [
  79. (r"/%s/(get)/([a-z0-9]+)?" % path, cls),
  80. ]
  81. def get(self, action, objid=None):
  82. if action != 'get':
  83. raise HTTPError(404)
  84. try:
  85. self.set_header('Access-Control-Allow-Origin', self.request.headers['Origin'])
  86. except KeyError:
  87. pass
  88. self.set_header('Content-Type', 'application/json')
  89. if objid is None:
  90. objid = self.get_by_url()
  91. try:
  92. response = self.get_object(objid)
  93. except:
  94. raise HTTPError(404)
  95. self.write(json.dumps(response))
  96. def get_by_url(self):
  97. try:
  98. url = self.request.arguments['url'][0]
  99. except (KeyError, IndexError):
  100. raise HTTPError(404)
  101. search = self.index.find(url=url)
  102. try:
  103. entry = next(search)
  104. except StopIteration:
  105. raise HTTPError(404)
  106. return entry['id']
  107. def get_object(self, objid):
  108. path = os.path.join(self.index.data_source, objid)
  109. md_path = path + '.metadata'
  110. obj = json.loads(open(path).read())
  111. if os.path.exists(md_path):
  112. obj.update(json.loads(open(md_path).read()))
  113. return obj
  114. class EffectGet(EffectSearcher):
  115. @asynchronous
  116. @engine
  117. def get(self, instance_id):
  118. objid = self.get_by_url()
  119. try:
  120. options = self.get_object(objid)
  121. presets = []
  122. for _, preset in options['presets'].items():
  123. presets.append({'label': preset['label']})
  124. options['presets'] = presets
  125. except:
  126. raise HTTPError(404)
  127. if self.request.connection.stream.closed():
  128. return
  129. self.write(json.dumps(options))
  130. self.finish()
  131. class EffectStylesheet(EffectSearcher):
  132. def get(self):
  133. objid = self.get_by_url()
  134. try:
  135. effect = self.get_object(objid)
  136. except:
  137. raise HTTPError(404)
  138. try:
  139. path = effect['gui']['stylesheet']
  140. except:
  141. raise HTTPError(404)
  142. if not os.path.exists(path):
  143. raise HTTPError(404)
  144. content = open(path).read()
  145. context = { 'ns': '?url=%s&bundle=%s' % (effect['url'], effect['package']) }
  146. self.set_header('Content-type', 'text/css')
  147. self.write(pyrender(content, context))
  148. class EffectResource(StaticFileHandler, EffectSearcher):
  149. def initialize(self):
  150. # Overrides StaticFileHandler initialize
  151. pass
  152. def get(self, path):
  153. try:
  154. objid = self.get_by_url()
  155. try:
  156. options = self.get_object(objid)
  157. except:
  158. raise HTTPError(404)
  159. try:
  160. document_root = options['gui']['resourcesDirectory']
  161. except:
  162. raise HTTPError(404)
  163. StaticFileHandler.initialize(self, document_root)
  164. StaticFileHandler.get(self, path)
  165. except HTTPError as e:
  166. if e.status_code != 404:
  167. raise e
  168. StaticFileHandler.initialize(self, os.path.join(HTML_DIR, 'resources'))
  169. StaticFileHandler.get(self, path)
  170. # ------------------------------------------------------------------------------------------------------------
  171. # WebServer Thread
  172. class WebServerThread(QThread):
  173. # signals
  174. running = pyqtSignal()
  175. def __init__(self, parent=None):
  176. QThread.__init__(self, parent)
  177. self.fApplication = Application(
  178. EffectSearcher.urls('effect') +
  179. [
  180. (r"/effect/get/?", EffectGet),
  181. (r"/effect/stylesheet.css", EffectStylesheet),
  182. (r"/resources/(.*)", EffectResource),
  183. (r"/(.*)", StaticFileHandler, {"path": HTML_DIR}),
  184. ],
  185. debug=True)
  186. self.fPrepareWasCalled = False
  187. def run(self):
  188. if not self.fPrepareWasCalled:
  189. self.fPrepareWasCalled = True
  190. self.fApplication.listen(PORT, address="0.0.0.0")
  191. self.running.emit()
  192. IOLoop.instance().start()
  193. def stopWait(self):
  194. IOLoop.instance().stop()
  195. return self.wait(5000)
  196. # ------------------------------------------------------------------------------------------------------------
  197. # Host Window
  198. class HostWindow(QMainWindow):
  199. # signals
  200. SIGTERM = pyqtSignal()
  201. SIGUSR1 = pyqtSignal()
  202. # --------------------------------------------------------------------------------------------------------
  203. def __init__(self):
  204. QMainWindow.__init__(self)
  205. gCarla.gui = self
  206. URI = sys.argv[1]
  207. # ----------------------------------------------------------------------------------------------------
  208. # Internal stuff
  209. self.fCurrentFrame = None
  210. self.fDocElemement = None
  211. self.fCanSetValues = False
  212. self.fNeedsShow = False
  213. self.fSizeSetup = False
  214. self.fQuitReceived = False
  215. self.fWasRepainted = False
  216. self.fPlugin = PluginSerializer(URI)
  217. self.fPorts = self.fPlugin.data['ports']
  218. self.fPortSymbols = {}
  219. self.fPortValues = {}
  220. for port in self.fPorts['control']['input'] + self.fPorts['control']['output']:
  221. self.fPortSymbols[port['index']] = port['symbol']
  222. self.fPortValues [port['index']] = port['default']
  223. # ----------------------------------------------------------------------------------------------------
  224. # Init pipe
  225. if len(sys.argv) == 7:
  226. self.fPipeClient = gCarla.utils.pipe_client_new(lambda s,msg: self.msgCallback(msg))
  227. else:
  228. self.fPipeClient = None
  229. # ----------------------------------------------------------------------------------------------------
  230. # Init Web server
  231. self.fWebServerThread = WebServerThread(self)
  232. self.fWebServerThread.start()
  233. # ----------------------------------------------------------------------------------------------------
  234. # Set up GUI
  235. self.fWebview = QWebView(self)
  236. self.setCentralWidget(self.fWebview)
  237. self.setContentsMargins(0, 0, 0, 0)
  238. #self.fWebview.settings().setAttribute(7, True)
  239. page = self.fWebview.page()
  240. page.setViewportSize(QSize(980, 600))
  241. mainFrame = page.mainFrame()
  242. mainFrame.setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
  243. mainFrame.setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
  244. settings = self.fWebview.settings()
  245. settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
  246. self.fWebview.loadFinished.connect(self.slot_webviewLoadFinished)
  247. url = "http://127.0.0.1:%s/icon.html#%s" % (PORT, URI)
  248. print("url:", url)
  249. self.fWebview.load(QUrl(url))
  250. # ----------------------------------------------------------------------------------------------------
  251. # Connect actions to functions
  252. self.SIGTERM.connect(self.slot_handleSIGTERM)
  253. # ----------------------------------------------------------------------------------------------------
  254. # Final setup
  255. self.fIdleTimer = self.startTimer(30)
  256. if self.fPipeClient is None:
  257. # testing, show UI only
  258. self.setWindowTitle("TestUI")
  259. self.fNeedsShow = True
  260. # --------------------------------------------------------------------------------------------------------
  261. def closeExternalUI(self):
  262. self.fWebServerThread.stopWait()
  263. if self.fPipeClient is None:
  264. return
  265. if not self.fQuitReceived:
  266. self.send(["exiting"])
  267. gCarla.utils.pipe_client_destroy(self.fPipeClient)
  268. self.fPipeClient = None
  269. def idleStuff(self):
  270. if self.fPipeClient is not None:
  271. gCarla.utils.pipe_client_idle(self.fPipeClient)
  272. self.checkForRepaintChanges()
  273. if self.fSizeSetup:
  274. return
  275. if self.fDocElemement is None or self.fDocElemement.isNull():
  276. return
  277. pedal = self.fDocElemement.findFirst(".mod-pedal")
  278. if pedal.isNull():
  279. return
  280. size = pedal.geometry().size()
  281. if size.width() <= 10 or size.height() <= 10:
  282. return
  283. # render web frame to image
  284. image = QImage(self.fWebview.page().viewportSize(), QImage.Format_ARGB32_Premultiplied)
  285. image.fill(Qt.transparent)
  286. painter = QPainter(image)
  287. self.fCurrentFrame.render(painter)
  288. painter.end()
  289. #image.save("/tmp/test.png")
  290. # get coordinates and size from image
  291. x = -1
  292. #y = -1
  293. lastx = -1
  294. lasty = -1
  295. for h in range(0, image.height()):
  296. hasNonTransPixels = False
  297. for w in range(0, image.width()):
  298. if image.pixel(w, h) not in (0, 0xff070707):
  299. hasNonTransPixels = True
  300. if x == -1 or x > w:
  301. x = w
  302. lastx = max(lastx, w)
  303. if hasNonTransPixels:
  304. #if y == -1:
  305. #y = h
  306. lasty = h
  307. # set size and position accordingly
  308. if -1 not in (x, lastx, lasty):
  309. self.setFixedSize(lastx-x, lasty)
  310. self.fCurrentFrame.setScrollPosition(QPoint(x, 0))
  311. else:
  312. self.setFixedSize(size)
  313. self.fCurrentFrame.setScrollPosition(QPoint(15, 0))
  314. # set initial values
  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. # Create GUI
  507. gui = HostWindow()
  508. # --------------------------------------------------------------------------------------------------------
  509. # App-Loop
  510. app.exit_exec()
  511. # ------------------------------------------------------------------------------------------------------------