diff options
-rwxr-xr-x | blc/audio/interface.py | 7 | ||||
-rw-r--r-- | blc/audio/mpv.py | 8 | ||||
-rw-r--r-- | blc/output.py | 4 | ||||
-rwxr-xr-x | blc/workspace.py | 217 |
4 files changed, 180 insertions, 56 deletions
diff --git a/blc/audio/interface.py b/blc/audio/interface.py index fff19cf..a33b2fe 100755 --- a/blc/audio/interface.py +++ b/blc/audio/interface.py @@ -48,6 +48,13 @@ class AudioPlayer(ABC): """Return if the player is playing or not.""" return + def terminate(self): + """Conduct any necessary cleanup here. + + No further methods of the player may be called after this one. + """ + return + def __init__(self, fname, args=()): self.fname = fname self.args = args diff --git a/blc/audio/mpv.py b/blc/audio/mpv.py index da21494..a71adf7 100644 --- a/blc/audio/mpv.py +++ b/blc/audio/mpv.py @@ -13,6 +13,7 @@ class MPVPlayer(AudioPlayer): def play(self, start=-1): ## TODO: Raise error if already playing? + self.started = True self.player.pause = False def pause(self): @@ -35,9 +36,10 @@ class MPVPlayer(AudioPlayer): @property def playing(self) -> bool: - if self.started: - return not (self.player.pause or self.player.eof_reached) - return False + return self.started and not (self.player.pause or self.player.eof_reached) + + def terminate(self): + self.player.terminate() def __init__(self, fname, args=()): super().__init__(fname, args) diff --git a/blc/output.py b/blc/output.py index dfe9a3d..45a79a0 100644 --- a/blc/output.py +++ b/blc/output.py @@ -24,7 +24,9 @@ class LightingOutput(ABC): channel entries may not be repeated. Each channel must be either an instance of Workspace.channel or a 2-tuple of integers (dmx universe, dmx channel). value must be between 0 and 255, inclusive. - inclusive. + + Note that this function need only be called when values change; the output is expected + to hold all channels at their last values. """ return diff --git a/blc/workspace.py b/blc/workspace.py index 1aa6712..eb17578 100755 --- a/blc/workspace.py +++ b/blc/workspace.py @@ -103,6 +103,7 @@ import os.path import subprocess as subp import xml.etree.ElementTree as etree import random +from typing import Any, List, Tuple, Sequence, Dict, Iterable, NewType, Callable, Union, Set ## BEGIN Constants @@ -123,11 +124,17 @@ PINGPONG = "PingPong" QXW = "{http://www.qlcplus.org/Workspace}" +## A pair (channel, value) +CVPair = NewType("CVPair", Tuple["Channel", int]) + +AudioInstance = NewType("AudioInstance", Tuple[int, int, str, int, int, int]) +Render = NewType("Render", Tuple[Iterable[CVPair], Iterable[AudioInstance], int, Any]) + ## END Constants ## BEGIN Utility functions -def ffprobe_audio_length(f, path="ffprobe"): +def ffprobe_audio_length(f: str, path: str = "ffprobe") -> int: """Use ffprobe to check audio length in milliseconds. Will always return the nearest whole millisecond greater than or equal to the duration. @@ -150,13 +157,22 @@ class Fixture: May be composed of multiple channels. """ + name: str + address_start: int + channel_count: int + mode: Any + universe: "Universe" + id: int + channnels: Tuple["Channel"] + def __hash__(self): 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) - def __init__(self, id_, name, address, universe, mode, channels=1): + def __init__(self, id_: int, name: str, address: int, universe: "Universe", mode: Any, + channels: int = 1): self.name = name self.address_start = address self.channel_count = channels @@ -165,10 +181,15 @@ class Fixture: 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)] + self.channels = tuple((Channel(self, i) for i in range(channels))) class Channel: """Class representing a single output channel.""" + fixture: Fixture + offset: int + address: int + universe: "Universe" + def __hash__(self): return self._hash @@ -178,7 +199,7 @@ class Channel: def __iter__(self): return iter((self.universe.id, self.address,)) - def __init__(self, fixture, offset): + def __init__(self, fixture: Fixture, offset: int): if offset >= fixture.channel_count or offset < 0: raise ValueError("Invalid offset") self.fixture = fixture @@ -189,6 +210,10 @@ class Channel: class ChannelGroup: """Class representing a group of output channels.""" + id: int + name: str + channels: Tuple[Channel, ...] + def __hash__(self): return self._hash @@ -196,7 +221,7 @@ class ChannelGroup: return "ChannelGroup(id=%d, name=%s, channels=(%s))" % (self.id, self.name, ", ".join((repr(c) for c in self.channels))) - def __init__(self, id_, name, channels): + def __init__(self, id_: int, name: str, channels: Tuple[Channel]): self.id = id_ self.name = name self.channels = tuple(channels) @@ -205,13 +230,16 @@ class ChannelGroup: class Universe: """Class representing an output universe.""" + id: int + name: str + def __hash__(self): return self._hash def __repr__(self): return "Universe(id=%d, name=%s)" % (self.id, self.name) - def __init__(self, id_, name): + def __init__(self, id_: int, name: str): self.id = id_ self.name = name @@ -250,24 +278,28 @@ class Function(ABC): As id is not necessarily unique, hash may be used to differentiate functions: this is guaranteed to be globally unique and is calculated once on instantiation. """ - repr_attr = ("id", "name",) + id: int + type: str + name: str + hidden: bool + duration: int + actual_duration: int + scope: Tuple[Channel, ...] + + repr_attr: Tuple[Union[str, Tuple[str, Callable[[Any], str]]], ...] = ("id", "name",) def __hash__(self): return self._hash @staticmethod - def get_data(): + def get_data() -> Any: """Return an initial state for the function. This function may not be a static method. Always invoke on an instance. """ return None - @staticmethod - def copy_data(data): - """Copy the given function state. - - This function may not be a static method. Always invoke on an instance. - """ + def copy_data(self, data: Any) -> Any: + """Copy the given function state.""" return None def __repr__(self): @@ -283,7 +315,7 @@ class Function(ABC): return "%s(%s)" % (self.__class__.__name__, ", ".join(buff)) @abstractmethod - def render(self, t: int, data=None): + def render(self, t: int, data: Any = None) -> Render: """Render the function at the given time. Parameters: @@ -316,9 +348,9 @@ class Function(ABC): It is an error to call render with data that has been used to render a future time; this is undefined behavior. """ - return - def __init__(self, id_, type_, name, scope, hidden=False, duration=-1, actual_duration=-1): + def __init__(self, id_: int, type_: str, name: str, scope: Iterable[Channel], + hidden: bool = False, duration: int = -1, actual_duration: int = -1): self.id = id_ self.type = type_ self.name = name @@ -332,10 +364,16 @@ class Function(ABC): class FadeFunction(Function): """QLC function that can fade in/out.""" - def __init__(self, id_, type_, name, scope, hidden=False, duration=-1, actual_duration=-1, fade_in=0, fade_out=0): + fade_in: int + fade_out: int + + def __init__(self, id_: int, type_: str, name: str, scope: Sequence[Channel], + hidden: bool = False, duration: int = -1, actual_duration: int = -1, + fade_in: int = 0, fade_out: int = 0): if fade_in >= QLC_INFTY or fade_out >= QLC_INFTY: raise ValueError("Fades cannot be infinite") - super().__init__(id_, type_, name, scope, hidden=hidden, duration=duration, actual_duration=actual_duration) + super().__init__(id_, type_, name, scope, hidden=hidden, duration=duration, + actual_duration=actual_duration) self.fade_in = min(QLC_INFTY, fade_in) self.fade_out = min(QLC_INFTY, fade_out) @@ -343,15 +381,14 @@ class FadeFunction(Function): class Advanceable(ABC): """Function that may be advanced.""" - @abstractstaticmethod - def advance(t, data): + def advance(self, t: int, data: Any): """Advance the function.""" return data class PreRenderable(ABC): """Function that may be pre-rendered.""" @abstractmethod - def render_all(self, minnx=10): + def render_all(self, minnx: int = 10): """Render the entire function. This may raise a ValueError if the Function is not actually PreRenderable; inheritance @@ -366,14 +403,52 @@ class PreRenderable(ABC): """ return () +class CacheRenderable(PreRenderable, ABC): + """Function that may be cached. + + Note that a function inheriting this need not be static under every possible definition of + that function, only some. + """ + _render = None + + class CacheRenderData: + last_idx: int + values: Dict[Channel, int] + + def __init__(self, last_idx: int, values: Dict[Channel, int]): + self.last_idx = last_idx + self.values = values + + def cache_render(self, minnx: int = 10) -> bool: + """Render the entire function. + + The result is that the function is essentially pre-rendered but behaves identically to a + regular function, i.e. that rendering is done using the function's render function as + usual. + + @returns True if the function was cached and False otherwise + """ + try: + render = self.render_all(minnx=minnx) + except ValueError: + return False + self._render = render + return True + + def _render_from_cache(self, t: int, data: CacheRenderData = None) -> Render: + raise NotImplementedError("Not ready yet") + ## END Base classes ## BEGIN Function classes class Audio(FadeFunction): """Class for a QLC+ audio function.""" + fname: str + run_order: str + repr_attr = ("id", "fname", "fade_in", "fade_out",) - def render(self, t, data=None): + def render(self, t: int, data = None): """Render the audio function. We do not seek to do anything related to audio in this library: the responsibility for @@ -385,7 +460,8 @@ class Audio(FadeFunction): 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): + def __init__(self, id_: int, name: str, fname: str, fade_in: int, fade_out: int, + length: int, run_order: str = SINGLESHOT, hidden: bool = False): super().__init__(id_, AUDIO, name, (), hidden=hidden, duration=length, actual_duration=length, fade_in=fade_in, fade_out=fade_out) self.fname = fname @@ -404,29 +480,42 @@ class Scene(Function): """All arguments are unused.""" return self.values, (), QLC_INFTY, data - 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) + def __init__(self, id_: int, name: str, values: Iterable[Tuple[Channel, int]], + hidden: bool = 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.""" + """A single step in a chaser. + + This function's methods are not intended to be used outside of Chaser's methods. + """ + function: Function + hold: int + repr_attr = ("id", "name", "hold", "fade_in", "fade_out", ("function", lambda f: f.id)) class ChaserStepData: """Data for the step.""" - def __init__(self, fd, start_time, end_time): + fd: Any + start_time: int + end_time: int + + def __init__(self, fd: Any, start_time: int, end_time: int): self.fd = fd self.start_time = start_time self.end_time = end_time - def get_data(self, start_time=0): - return self.ChaserStepData(fd=self.function.get_data(), start_time=start_time, end_time=self.duration) + def get_data(self, start_time: int = 0): + return self.ChaserStepData(fd=self.function.get_data(), start_time=start_time, + end_time=self.duration) @classmethod def copy_data(cls, data): return cls.ChaserStepData(fd=data.fd, start_time=data.start_time, end_time=data.end_time) - def render(self, t, data:ChaserStepData=None): + def render(self, t: int, data:ChaserStepData=None): """DO NOT CALL OUTSIDE OF Chaser. The logic is different here: we never check the actual duration of this function and @@ -443,7 +532,7 @@ class ChaserStep(FadeFunction): ## Render the function at time t values, acues, nx, data.fd = self.function.render(t, data=data.fd) ## Determine the multiplier - mul = 1 + mul = 1.0 if self.fade_in > 0 and t < self.fade_in: ## Fade in first mul = min(1,t/self.fade_in) nx = 1 @@ -473,11 +562,10 @@ class ChaserStep(FadeFunction): return (values, mul), tuple(nacues), nx, data - def __init__(self, id_, fade_in, fade_out, hold, function): + def __init__(self, id_: int, fade_in: int, fade_out: int, hold: int, function: Function): super().__init__(id_, STEP, function.name, function.scope, hidden=False, duration=hold+fade_in, actual_duration=hold+fade_out+fade_in, fade_in=fade_in, fade_out=fade_out) - self.id = id_ self.hold = hold self.function = function self._hash = hash((self._hash, self.function, self.hold)) @@ -486,7 +574,13 @@ class Chaser(Function, Advanceable, PreRenderable): """Class for representing a QLC+ Chaser or Sequence. Since they essentially do the same thing (Chaser being more general), they have only one - class here.""" + class here. + """ + steps: Tuple[ChaserStep, ...] + run_order: str + direction: str + is_chaser: bool + repr_attr = ("id", "name", ("steps", lambda s: ",".join((str(i.id) for i in s)))) class ChaserData: """Current state of a chaser. @@ -495,17 +589,23 @@ class Chaser(Function, Advanceable, PreRenderable): [(step number, step data), ...] """ + step_data: List[Tuple[int, ChaserStep.ChaserStepData]] + obey_loop: bool + forward: bool + @property - def current_step(self): + def current_step(self) -> int: + """Returns the index of the current step.""" return self.step_data[-1][0] if self.step_data else -1 - def __init__(self, step_data, obey_loop, forward): - self.step_data = step_data + def __init__(self, step_data: Iterable[Tuple[int, ChaserStep.ChaserStepData]], obey_loop: bool, + forward: bool = True): + self.step_data = list(step_data ) self.obey_loop = obey_loop - self.forward = True + self.forward = forward @staticmethod - def advance(t, data): + def advance(t: int, data): """End the current chaser step. After calling this function, the chaser must be rendered at a time at least t before @@ -520,10 +620,12 @@ class Chaser(Function, Advanceable, PreRenderable): return self.ChaserData(step_data=[], obey_loop=True, forward=True) def copy_data(self, data): + if data is None: + return None return self.ChaserData(step_data=[(a,self.steps[a].copy_data(b)) for a,b in data.step_data], obey_loop=data.obey_loop, forward=data.forward) - def next_step(self, data): ## TODO: Implement other chaser types + def next_step(self, data) -> Tuple[int, "Chaser.ChaserData"]: ## TODO: Implement other chaser types """Return the next step in the chaser. Returns a tuple (next step number, data) @@ -607,7 +709,7 @@ class Chaser(Function, Advanceable, PreRenderable): return tuple(vals.items()), tuple(sorted(acues, key=lambda a: a[0])), nx, data - def render_all(self, minnx=1): + def render_all(self, minnx: int = 1): """Render the entire Chaser.""" ## Verify that we can render this one if self.run_order != SINGLESHOT: @@ -659,7 +761,8 @@ class Chaser(Function, Advanceable, PreRenderable): return tuple(steps) - def __init__(self, id_, name, steps, hidden=False, run_order=SINGLESHOT, direction=FORWARD): + def __init__(self, id_: int, name: str, steps: Iterable[ChaserStep], hidden: bool = False, + run_order: str = SINGLESHOT, direction: str = FORWARD, is_chaser: str = False): if run_order not in (LOOP, SINGLESHOT): raise NotImplementedError("Only Loop and SingleShot chasers are currently supported") if direction not in (FORWARD,): @@ -683,10 +786,14 @@ class Chaser(Function, Advanceable, PreRenderable): self.steps = tuple(steps) self.run_order = run_order self.direction = direction + self.is_chaser = is_chaser self._hash = hash((self._hash, self.steps, self.run_order, self.direction)) class ShowFunction(Function): """Class for representing a function in a show.""" + function: Function + start_time: int + repr_attr = ("id", "name", "start_time", ("function", lambda f: f.id)) def render(self, t, data=None): if data is None: @@ -696,7 +803,7 @@ class ShowFunction(Function): 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): + def __init__(self, id_: int, name: str, function: Function, start_time: int): if function.actual_duration >= QLC_INFTY: raise ValueError("Cannot have infinite-length functions in shows") super().__init__(id_, "ShowFunction", name, function.scope, duration=function.duration, @@ -707,6 +814,7 @@ class ShowFunction(Function): class ShowTrack(Function): """Class for representing a track in a show.""" + functions: Tuple[ShowFunction, ...] repr_attr = ("id", "name", ("functions", lambda fs: ','.join(("%d@%d" % (f.function.id, f.start_time) for f in fs)))) def get_data(self): return tuple((f.function.get_data() for f in self.functions)) @@ -743,7 +851,7 @@ class ShowTrack(Function): return tuple(values.items()), tuple(sorted(acues, key=lambda a: a[0])), nx, data - def __init__(self, id_, name, functions): + def __init__(self, id_: int, name: str, functions: Iterable[ShowFunction]): dur = -1 adur = -1 self.functions = tuple(sorted(functions, key=lambda f: f.start_time)) @@ -757,12 +865,16 @@ class ShowTrack(Function): super().__init__(id_, "ShowTrack", name, scope, duration=dur, actual_duration=adur) self._hash = hash((self._hash, self.functions)) -class Show(Function, PreRenderable): +class Show(Function, CacheRenderable): """Class representing a QLC+ show.""" + tracks: Tuple[ShowTrack, ...] + def get_data(self): return tuple((t.get_data() for t in self.tracks)) def copy_data(self, data): + if isinstance(data, CacheRenderable.CacheRenderData): + return data.copy() return tuple((t.copy_data(d) for t,d in zip(self.tracks, data))) def render(self, t, data=None): @@ -851,7 +963,7 @@ class Show(Function, PreRenderable): return ((tuple(cues), tuple(sorted(acues, key=lambda a: a[1])),),) - def __init__(self, id_, name, tracks): + def __init__(self, id_: int, name: str, tracks: Iterable[ShowTrack]): scope = set() dur = -1 adur = -1 @@ -875,7 +987,7 @@ class Workspace: Should be created using Workspace.load and is assumed to be immutable. """ @classmethod - def load(cls, wfname, audio_length=ffprobe_audio_length): + def load(cls, wfname: str, audio_length: Callable[[str], int] = ffprobe_audio_length): """Load a QLC+ workspace. This function returns the created Workspace object. @@ -891,8 +1003,9 @@ class Workspace: wdir = os.path.dirname(os.path.abspath(wfname)) creator = ws.find(QXW+"Creator") - self = cls(creator.find(QXW+"Name").text, creator.find(QXW+"Version").text, - creator.find(QXW+"Author").text) + self = cls(creator.find(QXW+"Name").text or "No Name", + creator.find(QXW+"Version").text or "No Version", + creator.find(QXW+"Author").text or "No Author") engine = ws.find(QXW+"Engine") @@ -1086,7 +1199,7 @@ class Workspace: steps.append(step) func = Chaser(sid, name, steps, hidden=hidden, run_order=func.find(QXW+"RunOrder").text, - direction=func.find(QXW+"Direction").text) + direction=func.find(QXW+"Direction").text, is_chaser=True) else: raise ValueError("Unhandled type %s" % ftype) @@ -1096,7 +1209,7 @@ class Workspace: return self - def __init__(self, creator, version, author): + def __init__(self, creator: str, version: str, author: str): self.universes = {} self.fixtures = {} self.channel_groups = {} |