blc - Simple program for rendering QLC+ workspaces properly/without crashing. Developed into BLC2.

git clone https://benconnors.ca/git-repos/blc

About | Log | Files | Refs

commit 9b3230d72efc51a504edf102090c219e9dd35f99
parent fff5e34c9864532b5e38e70b658eccb0ff35d1d3
Author: Ben Connors <benconnors@outlook.com>
Date:   Thu, 24 Jan 2019 21:14:02 -0500

Move stuff around; fix rendering

- Now actually able to render shows
- Maybe able to render chasers
- Add untested OLA lighting output
- General fixes

Diffstat:
Daudio.py | 154-------------------------------------------------------------------------------
R__init__.py -> blc/__init__.py | 0
Ablc/audio.py | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rimage.py -> blc/image.py | 0
Ablc/ola.py | 37+++++++++++++++++++++++++++++++++++++
Ablc/output.py | 40++++++++++++++++++++++++++++++++++++++++
Ablc/render.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ablc/tk.py | 38++++++++++++++++++++++++++++++++++++++
Rworkspace.py -> blc/workspace.py | 0
Doutput.py | 27---------------------------
Drender.py | 85-------------------------------------------------------------------------------
Dtk.py | 32--------------------------------
12 files changed, 382 insertions(+), 298 deletions(-)

diff --git a/audio.py b/audio.py @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 - -"""Audio module for BLC. - -This module defines an AudioPlayer interface which allows for various audio backends to be used -interchangeably. It also defines a bare-bones better-than-nothing "FFPlayer" implementation and -a better "MPVPlayer" implementation. - -"DefaultAudioPlayer" should be used in general and will refer to MPVPlayer if available and -FFPlayer otherwise. -""" - -import atexit -import subprocess as subp -import time -import warnings - -from abc import ABC, abstractmethod, abstractproperty - -def ttoti(t): - """Convert seconds to milliseconds.""" - return int(1000*t + 0.5) - -def titot(ti): - """Convert milliseconds to seconds.""" - return ti/1000 - -class AudioPlayer(ABC): - """Class for playing audio. - - All time indices must be integers in milliseconds. - """ - @abstractmethod - def play(self, start=-1): - """Play the audio from the given time. - - If start is -1, play it from the current time index (e.g. if paused). If the player is - already playing, throw an error. - """ - return - - @abstractmethod - def seek(self, t): - """Seek to the given time index.""" - return - - @abstractmethod - def pause(self): - """Pause the player.""" - return - - @abstractmethod - def stop(self): - """Stop the player and reset to the first time index.""" - return - - @abstractproperty - def volume(self): - """Get or set the current volume.""" - return - - @abstractproperty - def position(self) -> int: - """The current position in milliseconds.""" - return - - @abstractproperty - def playing(self) -> bool: - """Return if the player is playing or not.""" - return - - def __init__(self, fname, args=()): - self.fname = fname - self.args = args - -class FFPlayer(AudioPlayer): - """Audio player using ffplay. - - Note that this is incredibly bad: the current position is guessed based on the start time of - the subprocess (meaning startup time of the ffplay process is counted in the current - position), no preloading of files is done, seeking is inaccurate and requires killing and - restarting the ffplay process, volume is ignored, and more. This is due to the fact that you - can't provide input to ffplay because it uses SDL exclusively for input (even though it can - be run without SDL?) so any change requires restarting the process. Use MPVPlayer if - possible. - """ - def play(self, start=-1): - if self.playing: - raise ValueError("Already playing") - - if start != -1: - self.start = titot(start) - self.player = subp.Popen(["ffplay", "-nodisp", "-autoexit", "-ss", str(self.start), *self.args, self.fname], - stdin=subp.DEVNULL, stdout=subp.DEVNULL, stderr=subp.DEVNULL) - atexit.register(self.stop) - self.start_time = time.monotonic() - - def stop(self): - if not self.playing: - return - self.player.terminate() - atexit.unregister(self.stop) - self.player = None - self.start = 0 - - def seek(self, t): - if self.playing: - self.stop() - self.start = titot(t) - self.play() - else: - self.start = titot(t) - - def pause(self): - if not self.playing: - return - self.stop() - self.start = self.start + time.monotonic() - - @property - def position(self): - if not self.playing: - return self.start - return ttoti(self.start + time.monotonic() - self.start_time) - - @property - def volume(self): - return 100 - - @volume.setter - def volume(self, vol): - return - - @property - def playing(self): - if self.player is not None: - if self.player.poll() is not None: - self.player = None - - return self.player is not None - - def __init__(self, fname, args=()): - super().__init__(fname, args=args) - - self.player = None - self.start = 0 - self.start_time = 0 - -try: - import mpv -except (OSError, ImportError): - warnings.warn("mpv backend unavailable, falling back to ffplay", RuntimeWarning) - - DefaultAudioPlayer = FFPlayer diff --git a/__init__.py b/blc/__init__.py diff --git a/blc/audio.py b/blc/audio.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 + +"""Audio module for BLC. + +This module defines an AudioPlayer interface which allows for various audio backends to be used +interchangeably. It also defines a bare-bones better-than-nothing "FFPlayer" implementation and +a better "MPVPlayer" implementation. + +"DefaultAudioPlayer" should be used in general and will refer to MPVPlayer if available and +FFPlayer otherwise. +""" + +import atexit +import subprocess as subp +import time +import warnings + +from abc import ABC, abstractmethod, abstractproperty + +def ttoti(t): + """Convert seconds to milliseconds.""" + return int(1000*t + 0.5) + +def titot(ti): + """Convert milliseconds to seconds.""" + return ti/1000 + +class AudioPlayer(ABC): + """Class for playing audio. + + All time indices must be integers in milliseconds. + """ + @abstractmethod + def play(self, start=-1): + """Play the audio from the given time. + + If start is -1, play it from the current time index (e.g. if paused). If the player is + already playing, throw an error. + """ + return + + @abstractmethod + def seek(self, t): + """Seek to the given time index.""" + return + + @abstractmethod + def pause(self): + """Pause the player.""" + return + + @abstractmethod + def stop(self): + """Stop the player and reset to the first time index.""" + return + + @abstractproperty + def volume(self): + """Get or set the current volume.""" + return + + @abstractproperty + def position(self) -> int: + """The current position in milliseconds.""" + return + + @abstractproperty + def playing(self) -> bool: + """Return if the player is playing or not.""" + return + + def __init__(self, fname, args=()): + self.fname = fname + self.args = args + +class FFPlayer(AudioPlayer): + """Audio player using ffplay. + + Note that this is incredibly bad: the current position is guessed based on the start time of + the subprocess (meaning startup time of the ffplay process is counted in the current + position), no preloading of files is done, seeking is inaccurate and requires killing and + restarting the ffplay process, volume is ignored, and more. This is due to the fact that you + can't provide input to ffplay because it uses SDL exclusively for input (even though it can + be run without SDL?) so any change requires restarting the process. Use MPVPlayer if + possible. + """ + def play(self, start=-1): + if self.playing: + raise ValueError("Already playing") + + if start != -1: + self.start = titot(start) + if self.start <= 0.1: + self.start = 0 + self.player = subp.Popen(["ffplay", "-nodisp", "-autoexit", "-ss", str(self.start), *self.args, self.fname], + stdin=subp.DEVNULL, stdout=subp.DEVNULL, stderr=subp.DEVNULL) + atexit.register(self.stop) + self.start_time = time.monotonic() + + def stop(self): + if not self.playing: + return + self.player.terminate() + atexit.unregister(self.stop) + self.player = None + self.start = 0 + + def seek(self, t): + if self.playing: + self.stop() + self.start = titot(t) + self.play() + else: + self.start = titot(t) + + def pause(self): + if not self.playing: + return + self.stop() + self.start = self.start + time.monotonic() + + @property + def position(self): + if not self.playing: + return self.start + return ttoti(self.start + time.monotonic() - self.start_time) + + @property + def volume(self): + return 100 + + @volume.setter + def volume(self, vol): + return + + @property + def playing(self): + if self.player is not None: + if self.player.poll() is not None: + self.player = None + + return self.player is not None + + def __init__(self, fname, args=()): + super().__init__(fname, args=args) + + self.player = None + self.start = 0 + self.start_time = 0 + +## try: +## import mpv +## except (OSError, ImportError): +## warnings.warn("mpv backend unavailable, falling back to ffplay", RuntimeWarning) + +DefaultAudioPlayer = FFPlayer diff --git a/image.py b/blc/image.py diff --git a/blc/ola.py b/blc/ola.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +"""Classes for use with the OLA project.""" + +import array + +from ola.OlaClient import OlaClient + +from .output import LightingOutput + +class OLAOutput(LightingOutput): + """An OLA client for BLC. + + universe_map must be a dictionary associating the numeric QLC+ universe ID with a numeric + OLA universe. + """ + def set_values(self, values): + send = set() + for c, v in values: + if c.universe.id in self.universe_map: + au = self.universe_map[c.universe.id] + else: + au = c.universe.id + if au not in self.universes: + self.universes[au] = array.array('B', (0 for i in range(512))) + uni = self.universes[au] + if uni[c.address] != v: + uni[c.address] = v + send.add(au) + for au in send: + self.client.SendDmx(au, self.universes[au]) + + def __init__(self, universe_map=None): + self.universe_map = universe_map if universe_map is not None else {} + self.client = OlaClient() + + self.universes = {0: array.array('B', (0 for i in range(512)))} diff --git a/blc/output.py b/blc/output.py @@ -0,0 +1,40 @@ +"""DMX module. + +Defines a generic interface for a DMX interface. +""" + +from abc import ABC, abstractmethod + +class LightingOutput(ABC): + """Generic lighting interface.""" + + ## Set this to how long it takes to transmit one set of values. May be ignored by client + ## code + trans_time = 1 + + @abstractmethod + def set_values(self, values): + """Set the current DMX values. + + values must be an iterable of the form: + + (channel, value), ... + + channel entries may not be repeated and each channel will be an instance of + workspace.Channel. value must be between 0 and 255, inclusive. + """ + return + +class ChainedLightingOutput(LightingOutput): + """Class for combining lighting outputs. + + Useful for having one output display current light values, while another actually outputs + the values. + """ + def set_values(self, values): + for o in self.outputs: + o.set_values(values) + + def __init__(self, *outputs): + self.outputs = outputs + self.trans_time = min((i.trans_time for i in outputs)) diff --git a/blc/render.py b/blc/render.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +import queue +import time +import threading + +from .audio import DefaultAudioPlayer, AudioPlayer +from .output import LightingOutput +from .workspace import SHOW, CHASER, Advanceable, QLC_INFTY + +class FunctionQueue: + def after(self, t: float, f: callable): + """Run the given function after t milliseconds.""" + self.queue.put((time.monotonic() + t/1000, f)) + + def start(self): + """Run until the queue is empty.""" + while not self.queue.empty(): + t, f = self.queue.get() + time.sleep(max(0, t-time.monotonic())) + f() + + def __init__(self): + self.queue = queue.SimpleQueue() + +class Renderer: + """Basic renderer for functions. + + Supports live-rendering Chasers and Shows. + + Instances of this class are NOT thread-safe, with the exception of the advance() method, + which may be called from other threads. + """ + def start(self): + """Start the function.""" + if self.start_time is not None: + raise ValueError("Already running") + self.fq.after(0, self.render_step) + self.fq.start() + self.nx = None + self.data = None + + def render_step(self): + """Output the current step and render the next one.""" + if self.nx not in (None, -1, QLC_INFTY): + self.fq.after(max(self.minnx, self.nx), self.render_step) + if self.nx is None: + self.nx = 0 + self.fq.after(0, self.render_step) + elif self.start_time is None: + self.start_time = time.monotonic() + + self.lo.set_values(tuple(self.values.items())) + for st, ap in self.anext: + ap.play(max(int((time.monotonic() - self.start_time)*1000+1)-st, 0)) + self.anext = [] + + if self.nx == QLC_INFTY: + ## Acquire the lock twice and block the process, we're stalled + self.stall_lock.acquire() + self.stall_lock.acquire() + ## Restart the rendering + self.fq.after(0, self.render_step) + + with self.data_lock: + if self.start_time is not None: + t = int((time.monotonic() - self.start_time)*1000 + 1) + self.nx + else: + t = 0 + vals, acues, self.nx, self.data = self.f.render(t) + for c, v in vals: + self.values[c] = v + for aid, st, fname, *_ in acues: + if aid not in self.aplayers: + self.aplayers.add(aid) + self.anext.append((st, self.ap(fname))) + + 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. + """ + with self.data_lock: + if self.start_time == -1: + raise ValueError("Cannot advance a function that has not been started!") + if issubclass(type(self.f), Advanceable): + t = 1000*int(time.monotonic() - self.start_time) + self.data = self.f.advance(self.data, time.monotonic() - self.start_time) + *_, self.data = self.f.render(t) + + ## This will make the lock unlocked + self.stall_lock.acquire(blocking=False) + self.stall_lock.release() + + def __init__(self, f, lo:LightingOutput, ap: AudioPlayer=DefaultAudioPlayer, minnx=-1): + if f.type not in (SHOW, CHASER): + raise ValueError("Only Shows and Chasers may be used as toplevel functions") + self.start_time = None + self.f = f + self.fq = FunctionQueue() + self.minnx = minnx + self.nx = None + self.data = None + self.data_lock = threading.Lock() + self.values = {c: 0 for c in self.f.scope} + self.lo = lo + self.ap = ap + self.aplayers = set() + self.anext = [] + self.stall_lock = threading.Lock() diff --git a/blc/tk.py b/blc/tk.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +"""Module containing Tk widgets for BLC.""" + +from .output import LightingOutput + +from tkinter import Frame, N, E, S, W, VERTICAL +from tkinter.ttk import Label, Scale + +class DMXView(Frame): + """Class for viewing DMX values.""" + def update_values(self, vals:tuple): + """Update the current values. + + Parameters: + vals: a tuple of (channel, value) pairs. values must be integers from 0 to 255, + inclusive. + """ + for c,v in vals: + self.channels[c-1-self.offset].set(255-v) + + def __init__(self, master, count=36, offset=0): + super().__init__(master) + + self.channels = [] + self.offset = 0 + self.rowconfigure(0,weight=1) + for c in range(count): + self.columnconfigure(c,weight=1) + s = Scale(self, from_=0, to=255, orient=VERTICAL, length=300) + s.grid(row=0, column=c, sticky=N+E+S+W) + s.set(255) + Label(self, text=str(c+1+offset)).grid(row=1, column=c,sticky=N+E+S+W) + self.channels.append(s) + +class TkOutput(LightingOutput, DMXView): + def set_values(self, values): + self.update_values(((c.address, v) for c,v in values)) diff --git a/workspace.py b/blc/workspace.py diff --git a/output.py b/output.py @@ -1,27 +0,0 @@ -"""DMX module. - -Defines a generic interface for a DMX interface. -""" - -from abc import ABC, abstractmethod - -class LightingOutput(ABC): - """Generic lighting interface.""" - - ## Set this to how long it takes to transmit one set of values. May be ignored by client - ## code - trans_time = 1 - - - @abstractmethod - def set_values(self, values): - """Set the current DMX values. - - values must be an iterable of the form: - - (channel, value), ... - - channel entries may not be repeated and each channel will be an instance of - workspace.Channel. value must be between 0 and 255, inclusive. - """ - return diff --git a/render.py b/render.py @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 - -import queue -import time -import threading - -from .audio import DefaultAudioPlayer, AudioPlayer -from .output import LightingOutput -from .workspace import SHOW, CHASER, Advanceable - -class FunctionQueue: - def after(self, t: float, f: callable): - """Run the given function after t milliseconds.""" - self.queue.put((time.monotonic() + t/1000, f)) - - def start(self): - """Run until the queue is empty.""" - while not self.queue.empty(): - t, f = self.queue.get() - time.sleep(max(0, time.monotonic() - t)) - f() - - def __init__(self): - self.queue = queue.SimpleQueue() - -class Renderer: - """Basic renderer for functions. - - Supports live-rendering Chasers and Shows. - - Instances of this class are NOT thread-safe, with the exception of the advance() method, - which may be called from other threads. - """ - def start(self): - if self.start_time != -1: - raise ValueError("Already running") - self.f.after(0, self.render_step) - self.f.start() - self.nx = None - self.data = None - self.vals = {} - - def render_step(self): - """Output the current step and render the next one.""" - if self.nx not in (None, -1): - self.fq.after((max(self.minnx, self.nx), self.render_step)) - elif self.nx is None: - self.start_time = time.monotonic() - - self.lo.set_values(tuple(self.values.items())) - - with self.data_lock: - t = 1000*(int((time.monotonic() - self.start_time)/1000 + 1) + self.nx) - vals, acues, self.nx, self.data = self.f.render(t) - for c, v in vals: - self.values[c] = v - - 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. - """ - with self.data_lock: - if self.start_time == -1: - raise ValueError("Cannot advance a function that has not been started!") - if issubclass(type(self.f), Advanceable): - t = 1000*int(self.monotonic() - self.start_time) - self.data = self.f.advance(self.data, time.monotonic() - self.start_time) - *_, self.data = self.f.render(t) - - def __init__(self, f, lo:LightingOutput, ao: AudioPlayer=DefaultAudioPlayer, minnx=-1): - if f.type not in (SHOW, CHASER): - raise ValueError("Only Shows and Chasers may be used as toplevel functions") - self.start_time = -1 - self.f = f - self.fq = FunctionQueue() - self.minnx = minnx - self.nx = None - self.data = None - self.data_lock = threading.Lock() - self.values = {c: 0 for c in self.f.scope} - self.lo = lo - self.ao = ao - self.aplayers = {} diff --git a/tk.py b/tk.py @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 - -"""Module containing Tk widgets for BLC.""" - -from tkinter import Frame, N, E, S, W, VERTICAL -from tkinter.ttk import Label, Scale - -class DMXView(Frame): - """Class for viewing DMX values.""" - def update_vals(self, vals:tuple): - """Update the current values. - - Parameters: - vals: a tuple of (channel, value) pairs. values must be integers from 0 to 255, - inclusive. - """ - for c,v in vals: - self.channels[c-1-self.offset].set(255-v) - - def __init__(self, master, count=36, offset=0): - super().__init__(master) - - self.channels = [] - self.offset = 0 - self.rowconfigure(0,weight=1) - for c in range(count): - self.columnconfigure(c,weight=1) - s = Scale(self, from_=0, to=255, orient=VERTICAL, length=300) - s.grid(row=0, column=c, sticky=N+E+S+W) - s.set(255) - Label(self, text=str(c+1+offset)).grid(row=1, column=c,sticky=N+E+S+W) - self.channels.append(s)