From 94da9ad80926a4ff38ced487f6246c819e0e4413 Mon Sep 17 00:00:00 2001 From: Ben Connors Date: Tue, 2 Jul 2019 16:37:21 -0400 Subject: Add Qt interface prototype --- blc-qt.py | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100755 blc-qt.py diff --git a/blc-qt.py b/blc-qt.py new file mode 100755 index 0000000..325c1d9 --- /dev/null +++ b/blc-qt.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 + +import locale +import argparse +import sys +import time + +from PyQt5.QtCore import Qt, QVariant, QTimer +from PyQt5.QtWidgets import QApplication, QPushButton, QWidget, QGroupBox, QLabel, QDialog, QSlider, QVBoxLayout, QHBoxLayout, QProxyStyle, QStyle, QMainWindow, QMenu, QAction, QFileDialog, QComboBox + +from blc.workspace import Workspace, CHASER, SHOW, QLC_INFTY +from blc.audio import DefaultAudioPlayer, AudioPlayer, FFPlayer +from blc.output import LightingOutput +from blc.ola import OLAOutput + +class DirectSliderStyle(QProxyStyle): + def styleHint(self, hint, option=0, widget=0, returnData=0): + if hint == QStyle.SH_Slider_AbsoluteSetButtons: + return (Qt.LeftButton | Qt.MidButton | Qt.RightButton) + return super().styleHint(hint, option, widget, returnData) + +class QFader(QGroupBox): + """Class for creating a single fader. + + Allows for the value to be set by the application and overriden by the user. + + @param title the title for the fader + @param parent the parent widget + """ + @property + def sliderValue(self): + """Return the fader's current value.""" + return self.slider.value() + + @sliderValue.setter + def sliderValue(self, value): + self._sliderValue = value + if not self.held: + self.slider.setValue(value) + + def _sliderPressed(self): + if not self.held: + self.held = True + self.button.setDisabled(False) + self._sliderMoved(self.slider.value()) + + def _releaseHold(self): + self.held = False + self.button.setDisabled(True) + self.slider.setValue(self._sliderValue) + self._sliderMoved(self._sliderValue) + + def _sliderMoved(self, value): + if self.on_update is not None: + self.on_update(self.channel, value) + + def __init__(self, title, channel=None, on_update=None, parent=None): + super().__init__(title=title, parent=parent) + self.slider = QSlider(Qt.Vertical, self) + self.slider.setStyle(DirectSliderStyle(self.slider.style())) + self.slider.setMaximum(255) + self.slider.setMinimum(0) + self.slider.setMinimumHeight(200) + + self.channel = channel + self.on_update = on_update + + self.slider.sliderMoved.connect(self._sliderMoved) + + self._sliderValue = 0 + self.held = False + + self.label = QLabel(self) + self.label.setText('0') + self.label.setAlignment(Qt.AlignCenter) + self.slider.valueChanged.connect(lambda a: self.label.setText(str(a))) + self.slider.sliderPressed.connect(self._sliderPressed) + + self.button = QPushButton("", self) + self.button.setDisabled(True) + self.button.clicked.connect(self._releaseHold) + + layout = QVBoxLayout() + layout.setAlignment(Qt.AlignCenter) + layout.addWidget(self.slider) + layout.addWidget(self.label) + layout.addWidget(self.button) + self.setLayout(layout) + + self.setFixedWidth(45) + +class QFaderBank(QGroupBox): + """Class for creating a bank of faders. + + @param title the title of the fader bank + @param faders a list of (address, title) elements, where address is unique + @param parent the parent widget + """ + def set_values(self, values): + values = tuple(values) + for c, v in values: + ua = (*c,) + if ua not in self.sliders: + continue + self.sliders[ua].sliderValue = v + if self.output is not None: + self.output.set_values(values) + + def slider_changed(self, channel, value): + if self.output is not None: + self.output.set_values(((channel, value),)) + + def __init__(self, title="Faders", faders=(), output=None, parent=None): + super().__init__(title=title, parent=parent) + + layout = QHBoxLayout() + layout.setSpacing(0) + + self.sliders = {} + for a, t in faders: + ua = (*a,) + slider = QFader(t, channel=ua, on_update=self.slider_changed, parent=self) + layout.addWidget(slider) + self.sliders[ua] = slider + + self.setLayout(layout) + + self.output = output + +class QtRenderer: + def _start(self): + """Start the function.""" + if self.running: + raise ValueError("Already running") + self.nx = 0 + self.tnx = 0 + self.data = None + self.time_elapsed = 0 + self.last_rendered = 0 + self.last_time = None + self.running = True + self.aplayers = set() + self.audio_players = [] + + def play(self): + if not self.running: + self._start() + else: + self.last_time = time.monotonic() + for ap in self.audio_players: + ap.play() + self.timer.start(self.minnx) + + def _prune_ap(self): + nap = [] + for i in self.audio_players: + if not i.playing: + i.terminate() + else: + nap.append(i) + self.audio_players = nap + + def pause(self): + self.timer.stop() + self._prune_ap() + for ap in self.audio_players: + ap.pause() + + def stop(self): + self.timer.stop() + self.running = False + self._prune_ap() + for ap in self.audio_players: + ap.stop() + + def _do_render(self): + t = int(1000*self.time_elapsed+1) + self.minnx + + vals, acues, self.nx, self.data = self.f.render(t, data=self.data) + self.nx = max(self.minnx, self.nx) + self.tnx = self.nx/1000 + + for c, v in vals: + self.values[c] = v + for st, aid, fname, *_ in acues: + if aid not in self.aplayers: + self.aplayers.add(aid) + ap = self.ap(fname) + ap.volume = 100 + self.audio_players.append(ap) + self.anext.append((st, ap)) + + self.last_rendered = self.time_elapsed + + def render_step(self): + """Output the current step and render the next one.""" + if self.last_time is None: + self._do_render() + self.nx = 0 + self.tnx = 0 + self.last_time = time.monotonic() + else: + t = time.monotonic() + self.time_elapsed += t - self.last_time + self.last_time = t + self.timestep(self.time_elapsed) + + self.lo.set_values(tuple(self.values.items())) + for st, ap in self.anext: + ap.play(max(int(self.time_elapsed*1000+1)-st, 0)) + self.anext = [] + + self._do_render() + + def advance(self): + """Advance the function, if possible. + + It is not an error to call this function when dealing with non-Advanceable toplevel + functions; this will just do nothing. + """ + ## TODO: Make this work when not stalled + if not self.running: + raise ValueError("Cannot advance a function that has not been started!") + + t = 1000*self.time_elapsed + self.data = self.f.advance(t, self.data) + *_, self.data = self.f.render(t, data=self.data) + self.nx = self.minnx + + def terminate(self): + for i in self.audio_players: + i.terminate() + + def __init__(self, f, timer: QTimer, timestep, lo:LightingOutput, + ap: AudioPlayer=DefaultAudioPlayer, minnx=20): + if f.type not in (SHOW, CHASER): + raise ValueError("Only Shows and Chasers may be used as toplevel functions") + + self.time_elapsed = 0 + self.last_rendered = 0 + self.last_time = 0 + self.f = f + self.minnx = minnx + self.nx = 0 + self.data = None + self.values = {c: 0 for c in self.f.scope} + self.lo = lo + self.ap = ap + self.aplayers = set() + self.audio_players = [] + self.anext = [] + self.timestep = timestep + self.timer = timer + self.running = False + self.tnx = 0 + self.tminnx = self.minnx/1000 + + self.timer.timeout.connect(self.render_step) + +class BLCQtMain(QMainWindow): + def blackout(self): + self.faders.set_values(((i, 0) for i in self.channels)) + + def ask_load_workspace(self): + fname, _ = QFileDialog.getOpenFileName(self, "Open File", "", "QLC Workspaces (*.qxw)") + if not fname: + return + self.load_workspace(fname) + + def load_workspace(self, fname): + ## Load the workspace + self.workspace = Workspace.load(fname) + + ## Iterate over all fixtures and set them to 0 + self.channels = sum((tuple(f.channels) for f in self.workspace.fixtures.values()), ()) + self.blackout() + + ## Update the list of functions + functions = sorted((f for f in self.workspace.functions.values() if f.type == SHOW or (f.type == CHASER and f.is_chaser) ), + key=lambda f: (f.type, f.name, f.id)) + self.function_select.clear() + for n, f in enumerate(functions): + self.function_select.insertItem(n, ("[C] " if f.type == CHASER else "[S] ")+f.name, QVariant((f.id))) + if functions: + self._choose_function(0) + + def _choose_function(self, index: int): + fid = self.function_select.currentData() + if self.function is not None and self.function.id == fid: + return + + self.function = self.workspace.functions[fid] + self.timer = QTimer() + if self.renderer is not None: + self.renderer.terminate() + self.renderer = QtRenderer(self.function, self.timer, self._update_time, self.faders) + self.func_label.setText("Current Function: None") + self._set_controls(True, False, False, False) + + def _update_time(self, t: float): + if 1000*t > self.function.actual_duration: + self._stop() + elif self.function.type == CHASER and self.renderer.data is not None: + cs = self.renderer.data.current_step + self.func_label.setText("Current Function: "+self.function.steps[cs].function.name+(" (%d)" % (cs+1))) + self.time_label.setText("Time: %.1fs" % t) + + def _set_controls(self, play, pause, stop, advance): + self.play_button.setEnabled(play) + self.pause_button.setEnabled(pause) + self.stop_button.setEnabled(stop) + self.advance_button.setEnabled(advance) + + def _play(self): + self._set_controls(False, True, True, True) + if self.function.type == SHOW: + self.func_label.setText("Current Function: Show") + self.function_select.setEnabled(False) + self.renderer.play() + + def _stop(self): + self.renderer.stop() + self._set_controls(True, False, False, False) + self.function_select.setEnabled(True) + self._update_time(0) + self.func_label.setText("Current Function: None") + self.blackout() + + def _pause(self): + self.renderer.pause() + self._set_controls(True, False, True, False) + + def _advance(self): + self.renderer.advance() + + def terminate(self): + if self.renderer is not None: + self.renderer.terminate() + + def __init__(self): + super().__init__() + + self.root = QWidget(self) + self.setWindowTitle("BLC-Qt") + + self.menu = self.menuBar() + self.fileMenu = self.menu.addMenu("File") + + loadw = QAction("Load Workspace", self.root) + loadw.triggered.connect(self.ask_load_workspace) + self.fileMenu.addAction(loadw) + + self.faders = QFaderBank(title="Channels", faders=(((0, i), str(i)) for i in range(1,37)), + parent=self.root) + + function_box = QGroupBox(self, title="Functions") + self.function_select = QComboBox(function_box) + self.function_select.activated.connect(self._choose_function) + + self.time_label = QLabel(function_box) + self.time_label.setText("Time: 0.0s") + self.func_label = QLabel(function_box) + self.func_label.setText("Current Function: None") + self.play_button = QPushButton("Play", function_box) + self.play_button.clicked.connect(self._play) + self.pause_button = QPushButton("Pause", function_box) + self.pause_button.clicked.connect(self._pause) + self.stop_button = QPushButton("Stop", function_box) + self.stop_button.clicked.connect(self._stop) + self.advance_button = QPushButton("Advance", function_box) + self.advance_button.clicked.connect(self._advance) + self._set_controls(False, False, False, False) + + fblayout = QVBoxLayout() + fblayout.addWidget(self.function_select) + fblayout.addWidget(self.func_label) + fblayout.addWidget(self.play_button) + fblayout.addWidget(self.stop_button) + fblayout.addWidget(self.pause_button) + fblayout.addWidget(self.advance_button) + fblayout.addWidget(self.time_label) + function_box.setLayout(fblayout) + + layout = QVBoxLayout() + layout.addWidget(self.faders) + layout.addWidget(function_box) + self.root.setLayout(layout) + + self.setCentralWidget(self.root) + + self.workspace = None + self.function = None + + self.timer = None + self.renderer = None + + self.channels = [] + +class PrintOutput: + def set_values(self, values): + for c, v in values: + _, a = c + #print("%3d to %3d" % (a, v)) + +parser = argparse.ArgumentParser() +parser.add_argument("workspace", nargs="?", action="store", type=str, help="workspace file to load") + +args = parser.parse_args() + +app = QApplication([]) + +## Necessary for MPV to work, the QApplication constructor changes this +locale.setlocale(locale.LC_NUMERIC, "C") + +main = BLCQtMain() +if args.workspace is not None: + main.load_workspace(args.workspace) +main.faders.output = OLAOutput() +main.show() +ret = app.exec_() +main.terminate() +sys.exit(ret) -- cgit v1.2.3