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.

685 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. super(EffectResource, self).initialize(document_root)
  164. super(EffectResource, self).get(path)
  165. except HTTPError as e:
  166. if e.status_code != 404:
  167. raise e
  168. super(EffectResource, self).initialize(os.path.join(HTML_DIR, 'resources'))
  169. super(EffectResource, self).get(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. page = self.fWebview.page()
  239. page.setViewportSize(QSize(980, 600))
  240. mainFrame = page.mainFrame()
  241. mainFrame.setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff)
  242. mainFrame.setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff)
  243. settings = self.fWebview.settings()
  244. settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
  245. self.fWebview.loadFinished.connect(self.slot_webviewLoadFinished)
  246. url = "http://127.0.0.1:%s/icon.html#%s" % (PORT, URI)
  247. print("url:", url)
  248. self.fWebview.load(QUrl(url))
  249. # ----------------------------------------------------------------------------------------------------
  250. # Connect actions to functions
  251. self.SIGTERM.connect(self.slot_handleSIGTERM)
  252. # ----------------------------------------------------------------------------------------------------
  253. # Final setup
  254. self.fIdleTimer = self.startTimer(30)
  255. if self.fPipeClient is None:
  256. # testing, show UI only
  257. self.setWindowTitle("TestUI")
  258. self.fNeedsShow = True
  259. # --------------------------------------------------------------------------------------------------------
  260. def closeExternalUI(self):
  261. self.fWebServerThread.stopWait()
  262. if self.fPipeClient is None:
  263. return
  264. if not self.fQuitReceived:
  265. self.send(["exiting"])
  266. gCarla.utils.pipe_client_destroy(self.fPipeClient)
  267. self.fPipeClient = None
  268. def idleStuff(self):
  269. if self.fPipeClient is not None:
  270. gCarla.utils.pipe_client_idle(self.fPipeClient)
  271. self.checkForRepaintChanges()
  272. if self.fSizeSetup:
  273. return
  274. if self.fDocElemement is None or self.fDocElemement.isNull():
  275. return
  276. pedal = self.fDocElemement.findFirst(".mod-pedal")
  277. if pedal.isNull():
  278. return
  279. size = pedal.geometry().size()
  280. if size.width() <= 10 or size.height() <= 10:
  281. return
  282. # render web frame to image
  283. image = QImage(self.fWebview.page().viewportSize(), QImage.Format_ARGB32_Premultiplied)
  284. image.fill(Qt.transparent)
  285. painter = QPainter(image)
  286. self.fCurrentFrame.render(painter)
  287. painter.end()
  288. #image.save("/tmp/test.png")
  289. # get coordinates and size from image
  290. x = -1
  291. #y = -1
  292. lastx = -1
  293. lasty = -1
  294. for h in range(0, image.height()):
  295. hasNonTransPixels = False
  296. for w in range(0, image.width()):
  297. if image.pixel(w, h) not in (0, 0xff070707):
  298. hasNonTransPixels = True
  299. if x == -1 or x > w:
  300. x = w
  301. lastx = max(lastx, w)
  302. if hasNonTransPixels:
  303. #if y == -1:
  304. #y = h
  305. lasty = h
  306. # set size and position accordingly
  307. if -1 not in (x, lastx, lasty):
  308. self.setFixedSize(lastx-x, lasty)
  309. self.fCurrentFrame.setScrollPosition(QPoint(x, 0))
  310. else:
  311. self.setFixedSize(size)
  312. self.fCurrentFrame.setScrollPosition(QPoint(15, 0))
  313. # set initial values
  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)" % (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:
  406. symbol = self.fPortSymbols[index]
  407. self.fCurrentFrame.evaluateJavaScript("icongui.setPortValue('%s', %f)" % (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. def timerEvent(self, event):
  443. if event.timerId() == self.fIdleTimer:
  444. self.idleStuff()
  445. QMainWindow.timerEvent(self, event)
  446. # --------------------------------------------------------------------------------------------------------
  447. @pyqtSlot()
  448. def slot_handleSIGTERM(self):
  449. print("Got SIGTERM -> Closing now")
  450. self.close()
  451. # --------------------------------------------------------------------------------------------------------
  452. # Internal stuff
  453. def readlineblock(self):
  454. if self.fPipeClient is None:
  455. return ""
  456. return gCarla.utils.pipe_client_readlineblock(self.fPipeClient, 5000)
  457. def send(self, lines):
  458. if self.fPipeClient is None or len(lines) == 0:
  459. return
  460. gCarla.utils.pipe_client_lock(self.fPipeClient)
  461. # this must never fail, we need to unlock at the end
  462. try:
  463. for line in lines:
  464. if line is None:
  465. line2 = "(null)"
  466. elif isinstance(line, str):
  467. line2 = line.replace("\n", "\r")
  468. elif isinstance(line, bool):
  469. line2 = "true" if line else "false"
  470. elif isinstance(line, int):
  471. line2 = "%i" % line
  472. elif isinstance(line, float):
  473. line2 = "%.10f" % line
  474. else:
  475. print("unknown data type to send:", type(line))
  476. return
  477. gCarla.utils.pipe_client_write_msg(self.fPipeClient, line2 + "\n")
  478. except:
  479. pass
  480. gCarla.utils.pipe_client_flush_and_unlock(self.fPipeClient)
  481. # ------------------------------------------------------------------------------------------------------------
  482. # Main
  483. if __name__ == '__main__':
  484. # -------------------------------------------------------------
  485. # Read CLI args
  486. if len(sys.argv) < 2:
  487. print("usage: %s <plugin-uri>" % sys.argv[0])
  488. sys.exit(1)
  489. libPrefix = os.getenv("CARLA_LIB_PREFIX")
  490. # -------------------------------------------------------------
  491. # App initialization
  492. app = CarlaApplication("Carla2-MODGUI", libPrefix)
  493. # -------------------------------------------------------------
  494. # Init utils
  495. pathBinaries, pathResources = getPaths(libPrefix)
  496. utilsname = "libcarla_utils.%s" % (DLL_EXTENSION)
  497. gCarla.utils = CarlaUtils(os.path.join(pathBinaries, utilsname))
  498. gCarla.utils.set_process_name("carla-bridge-lv2-modgui")
  499. # -------------------------------------------------------------
  500. # Set-up custom signal handling
  501. setUpSignals()
  502. # -------------------------------------------------------------
  503. # Create GUI
  504. gui = HostWindow()
  505. # --------------------------------------------------------------------------------------------------------
  506. # App-Loop
  507. app.exit_exec()
  508. # ------------------------------------------------------------------------------------------------------------