diff options
-rwxr-xr-x | image.py | 2 | ||||
-rw-r--r-- | output.py | 27 | ||||
-rw-r--r-- | render.py | 85 | ||||
-rwxr-xr-x | workspace.py | 85 |
4 files changed, 157 insertions, 42 deletions
@@ -109,7 +109,7 @@ def render_image_show(s:Show): ## Add 0 entries as necessary to the audio list atime += [0]*max(0,skipperiods-len(atime)) - tname, wave = get_wave(a[1]) + tname, wave = get_wave(a[2]) nchannels, sampwidth, framerate, nframes, *_ = wave.getparams() if sampwidth != 2: raise ValueError("Only 16-bit wave is supported") diff --git a/output.py b/output.py new file mode 100644 index 0000000..b1e88e1 --- /dev/null +++ b/output.py @@ -0,0 +1,27 @@ +"""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 new file mode 100644 index 0000000..833b5f5 --- /dev/null +++ b/render.py @@ -0,0 +1,85 @@ +#!/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/workspace.py b/workspace.py index ed40634..c9f9a35 100755 --- a/workspace.py +++ b/workspace.py @@ -146,15 +146,13 @@ def ffprobe_audio_length(f, path="ffprobe"): ## END Utility functions ## BEGIN Topology classes - class Fixture: """Class representing a single light fixture. May be composed of multiple channels. """ def __hash__(self): - return hash((self.name, self.address_start, self.channel_count, self.mode, self.id, - self.universe)) + return self._hash def __repr__(self): return "Fixture(id=%d, name=%s, universe=%d, start=%d, channels=%d)" % (self.id, self.name, self.universe.id, self.address_start, self.channel_count) @@ -166,13 +164,15 @@ class Fixture: self.mode = mode self.universe = universe self.id = id_ + self._hash = hash((self.name, self.address_start, self.channel_count, self.mode, + self.id, self.universe)) self.channels = [Channel(self, i) for i in range(channels)] class Channel: """Class representing a single output channel.""" def __hash__(self): - return hash((self.fixture, self.offset, self.address)) - + return self._hash + def __repr__(self): return "Channel(address=%d)" % (self.address) @@ -183,12 +183,13 @@ class Channel: self.offset = offset self.address = self.fixture.address_start + offset self.universe = self.fixture.universe + self._hash = hash((self.fixture, self.offset, self.address)) class ChannelGroup: """Class representing a group of output channels.""" def __hash__(self): - return hash(self.id, self.name, self.channels) - + return self._hash + def __repr__(self): return "ChannelGroup(id=%d, name=%s, channels=(%s))" % (self.id, self.name, ", ".join((repr(c) for c in self.channels))) @@ -198,10 +199,12 @@ class ChannelGroup: self.name = name self.channels = tuple(channels) + self._hash = hash((self.id, self.name, self.channels)) + class Universe: """Class representing an output universe.""" def __hash__(self): - return hash((self.id, self.name)) + return self._hash def __repr__(self): return "Universe(id=%d, name=%s)" % (self.id, self.name) @@ -210,6 +213,8 @@ class Universe: self.id = id_ self.name = name + self._hash = hash((self.id, self.name)) + ## END Toplogy classes ## BEGIN Base classes @@ -235,8 +240,7 @@ class Function(ABC): """ repr_attr = ("id", "name",) def __hash__(self): - return hash((self.id, self.type, self.name, self.scope, self.hidden, self.duration, - self.actual_duration)) + return self._hash @staticmethod def get_data(): @@ -271,7 +275,9 @@ class Function(ABC): (values, audio cues, next change, data) Where values is a tuple of (channel, value) elements, audio_cues is a tuple of - (filename, start time, fade in time, fade out time, fade out start) elements, + (filename, aid, start time, fade in time, fade out time, fade out start) elements, aid + may be used to uniquely identify instances of audio cues. + next_change is the time index of the next lighting change, and data is the state data (None if unused). values must contain a value for exactly those channels provided in scope. @@ -298,11 +304,11 @@ class Function(ABC): self.actual_duration = min(QLC_INFTY, actual_duration) self.scope = tuple(scope) + self._hash = hash((self.id, self.type, self.name, self.scope, self.hidden, self.duration, + self.actual_duration)) + class FadeFunction(Function): """QLC function that can fade in/out.""" - def __hash__(self): - return hash((super().__hash__(), self.fade_in, self.fade_out)) - def __init__(self, id_, type_, name, scope, hidden=False, duration=-1, actual_duration=-1, fade_in=0, fade_out=0): if fade_in >= QLC_INFTY or fade_out >= QLC_INFTY: raise ValueError("Fades cannot be infinite") @@ -310,6 +316,15 @@ class FadeFunction(Function): self.fade_in = min(QLC_INFTY, fade_in) self.fade_out = min(QLC_INFTY, fade_out) + self._hash = hash((self._hash, self.fade_in, self.fade_out)) + +class Advanceable(ABC): + """Function that may be advanced.""" + @abstractmethod + def advance(self, data): + """Advance the function.""" + return + ## END Base classes ## BEGIN Function classes @@ -317,26 +332,24 @@ class FadeFunction(Function): class Audio(FadeFunction): """Class for a QLC+ audio function.""" repr_attr = ("id", "fname", "fade_in", "fade_out",) - def __hash__(self): - return hash((super().__hash__(), self.fname, self.run_order)) - def render(self, t, data=None): """Render the audio function. We do not seek to do anything related to audio in this library: the responsibility for mixing, fading, playing, probing, etc. the audio file is with the specific application. - As such, this function only returns the relevant data for the audio function. + As such, this function only returns the relevant data for the audio function.o """ if t > self.duration: return (), (), -1, data - return (), ((0, self.fname, self.fade_in, self.fade_out, self.duration-self.fade_out),), self.duration+1-t, data + return (), ((0, self.id, self.fname, self.fade_in, self.fade_out, self.duration-self.fade_out),), self.duration+1-t, data def __init__(self, id_, name, fname, fade_in, fade_out, length, run_order=SINGLESHOT, hidden=False): super().__init__(id_, AUDIO, name, (), hidden=hidden, duration=length, actual_duration=length, fade_in=fade_in, fade_out=fade_out) self.fname = fname self.run_order = run_order + self._hash = hash((self._hash, self.fname, self.run_order)) class Scene(Function): """Class for a QLC Scene. @@ -346,9 +359,6 @@ class Scene(Function): Scenes are mostly meaningless on their own in this context, they must be attached to a chaser/show to do anything. """ - def __hash__(self): - return hash((super().__hash__(), self.values)) - def render(self, t, data=None): """All arguments are unused.""" return self.values, (), QLC_INFTY, None @@ -356,6 +366,7 @@ class Scene(Function): def __init__(self, id_, name, values, hidden=False): super().__init__(id_, SCENE, name, (c for c,v in values), hidden=hidden, duration=-1, actual_duration=-1) self.values = tuple(values) + self._hash = hash((self._hash, self.values)) class ChaserStep(FadeFunction): """A single step in a chaser.""" @@ -367,9 +378,6 @@ class ChaserStep(FadeFunction): self.start_time = start_time self.end_time = end_time - def __hash__(self): - return hash((super().__hash__(), self.hold, self.function)) - def get_data(self, start_time=0): return self.ChaserStepData(fd=self.function.get_data(), start_time=start_time, end_time=self.duration) @@ -405,11 +413,12 @@ class ChaserStep(FadeFunction): nx = min(nx, data.end_time-t) nacues = [] - for f, s, fin, fout, fstart in acues: + for s, aid, f, fin, fout, fstart in acues: if fstart + fout > self.fade_out + data.end_time: fstart = data.end_time - self.fade_out fout = self.fade_out - nacues.append((f, s+data.start_time, max(self.fade_in, fin), fout, fstart)) + nacues.append((s+data.start_time, hash((self.id, data.start_time, aid)), + f, max(self.fade_in, fin), fout, fstart)) return (values, mul), tuple(nacues), nx, data @@ -420,8 +429,9 @@ class ChaserStep(FadeFunction): self.id = id_ self.hold = hold self.function = function + self._hash = hash((self._hash, self.function, self.hold)) -class Chaser(Function): +class Chaser(Function, Advanceable): """Class for representing a QLC+ Chaser or Sequence. Since they essentially do the same thing (Chaser being more general), they have only one @@ -433,9 +443,6 @@ class Chaser(Function): self.step_data = step_data self.obey_loop = obey_loop - def __hash__(self): - return hash((super().__hash__(), self.steps, self.run_order, self.direction)) - @staticmethod def advance(t, data): """End the current chaser step. @@ -594,19 +601,18 @@ class Chaser(Function): self.steps = tuple(steps) self.run_order = run_order self.direction = direction + self._hash = hash((self._hash, self.steps, self.run_order, self.direction)) class ShowFunction(Function): """Class for representing a function in a show.""" repr_attr = ("id", "name", "start_time", ("function", lambda f: f.id)) - def __hash__(self): - return hash((super().__hash__(), self.function, self.start_time)) - def render(self, t, data=None): if data is None: data = self.function.get_data() values, acues, nx, data = self.function.render(t-self.start_time, data=data) - return values, tuple(((at+self.start_time,*others) for at,*others in acues)), nx, data + return values, tuple(((at+self.start_time,hash((self.id, self.start_time, aid)), + *others) for at,aid,*others in acues)), nx, data def __init__(self, id_, name, function, start_time): if function.actual_duration >= QLC_INFTY: @@ -615,13 +621,11 @@ class ShowFunction(Function): actual_duration=function.actual_duration) self.function = function self.start_time = start_time + self._hash = hash((self._hash, self.start_time, self.function)) class ShowTrack(Function): """Class for representing a track in a show.""" repr_attr = ("id", "name", ("functions", lambda fs: ','.join(("%d@%d" % (f.function.id, f.start_time) for f in fs)))) - def __hash__(self): - return hash((super().__hash__(), self.functions)) - def get_data(self): return tuple((f.function.get_data() for f in self.functions)) @@ -664,12 +668,10 @@ class ShowTrack(Function): dur = f.start_time + f.duration scope.update(f.scope) super().__init__(id_, "ShowTrack", name, scope, duration=dur, actual_duration=adur) + self._hash = hash((self._hash, self.functions)) class Show(Function): """Class representing a QLC+ show.""" - def __hash__(self): - return hash((super().__hash__(), self.tracks)) - def render(self, t, data=None): if t > self.actual_duration: return (), (), -1, data @@ -768,6 +770,7 @@ class Show(Function): adur = t.actual_duration super().__init__(id_, SHOW, name, scope, duration=dur, actual_duration=adur) self.tracks = tuple(tracks) + self._hash = hash((self._hash, self.tracks)) ## END Function classes |