From dfe20c0430c7d58b57c44026102cf8b3c52ac1b3 Mon Sep 17 00:00:00 2001 From: Ben Connors Date: Thu, 26 Sep 2019 21:42:14 -0400 Subject: Lots of stuff - Add tests for chaser steps - Finish preliminary implementation of chasers - Implement (de)serialization on chasers and steps - Various bugfixes from testing --- blc2/functions/chaser.py | 148 +++++++++++++++++++++++++++++++++---- blc2/functions/chaserstep.py | 112 ++++++++++++++++++++++------ blc2/functions/function.py | 12 ++- blc2/functions/scene.py | 2 +- blc2/workspace.py | 37 ++++++++-- examples/workspace.xml | 4 +- tests/test_functions_chaserstep.py | 124 +++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+), 48 deletions(-) create mode 100644 tests/test_functions_chaserstep.py diff --git a/blc2/functions/chaser.py b/blc2/functions/chaser.py index d17b8f4..a6699f8 100644 --- a/blc2/functions/chaser.py +++ b/blc2/functions/chaser.py @@ -1,11 +1,11 @@ """Module for basic chasers.""" import random +import xml.etree.ElementTree as et -from ..constants import CHASER, INFTY, ONESHOT, LOOP, RANDOM +from ..constants import CHASER, INFTY, ONESHOT, LOOP, RANDOM, BXW from .function import Function -from .chaserstep import ChaserStep - +from ..exceptions import LoadError class Chaser(Function): """Class for chasers.""" @@ -28,6 +28,17 @@ class Chaser(Function): self.steps = list(steps) self.audio_id = 0 + @property + def advance_mode(self): + """Return the function's current advance mode.""" + return self._advance_mode + + @advance_mode.setter + def advance_mode(self, v): + if v != self._advance_mode: + self._advance_mode = v + self._recalculate() + @property def fade_in(self): return 0 @@ -36,8 +47,12 @@ class Chaser(Function): def fade_out(self): return 0 - def get_data(self): - return self.ChaserData(self) + def get_data(self, start_at=None): #pylint: disable=arguments-differ + data = self.ChaserData(self) + if start_at is not None: + data = self.advance(0, data, n=start_at) + + return data def copy_data(self, data): return self.ChaserData(self, steps=[i.copy() for i in data.steps]) @@ -107,6 +122,77 @@ class Chaser(Function): else: return range(n, len(self.steps)) + def _fix_indices(self): + for i, s in enumerate(self._steps): + if s.index != i: + s.index._set_index(i) #pylint: disable=protected-access + + def register_step(self, step): + """Register a new step.""" + if step.index == -1: + step._index = len(self._steps) #pylint: disable=protected-access + elif step.index is None: + ## Add it to the end + step._index = len(self._steps) #pylint: disable=protected-access + elif step.index > len(self._steps): + step._index = len(self._steps) #pylint: disable=protected-access + self._steps.insert(step.index, step) + self.w.register_function_delete_callback(step, self._step_deleted, self) + self._fix_indices() + + def move_step(self, step, position): + """Move a step around.""" + if isinstance(step, int): + step = self._steps[step] + elif step not in self._steps: + raise ValueError("No such step") + + if position == -1 or position >= len(self._steps): + position = len(self._steps)-1 + + if position < 0: + raise ValueError("Step index must be nonnegative") + elif step.index == position: ## Pointless change + return + + self._steps.pop(step.index) + self._steps.insert(position, step) + self._fix_indices() + + def delete_step(self, step): + """Delete a step.""" + if isinstance(step, int): + step = self._steps[step] + elif step not in self._steps: + raise ValueError("No such step") + + step.delete() + + def advance(self, t, data, n=None): + """Advance the chaser at the given time. + + If ``n`` is ``None``, the chaser is advanced one step. Otherwise, it is advanced to + the given position. + """ + if n is None: + for i in self.next_steps(data): + n = i + break + else: + raise ValueError("Chaser is finished") + if data.steps: + data.steps[-1].end_time = t + data.steps.append(self.steps[n]._get_data(t, n)) + data.audio_id += 1 + + return data + + def _step_deleted(self, step): + self._steps.pop(step.index) + ## Nullify the step's index so we'll catch it when rendering + step._index = -1 #pylint: disable=protected-access + self._fix_indices() + def render(self, t, data=None): if data is None: data = self.get_data() @@ -116,9 +202,17 @@ class Chaser(Function): if data.audio_id != 0: raise ValueError("Audio ID must be zero") n = self.first_step - sd = self.steps[n]._get_data(0, n) #pylint: disable=protected-access + sd = self.steps[n]._get_data(0, 0) #pylint: disable=protected-access + data.audio_id += 1 data.steps.append(sd) + if data.steps[-1].step.index == -1: + ## Have to do some surgery, the last step was deleted + ## Just use the closest one, don't worry too much + n = max(len(self.steps)-1, data.steps[-1].index) + data.steps.append(self.steps[n]._get_data(t, data.audio_id)) #pylint: disable=protected-access + data.audio_id += 1 + ## Make sure we have all the steps we need st = data.steps[-1].duration + data.steps[-1].start_time if st < INFTY and st <= t: @@ -126,16 +220,16 @@ class Chaser(Function): s = self.steps[n] st += s.duration et += s.actual_duration - data.audio_id += 1 if et >= t: ## We need this one - data.steps.append(self.steps[n]._get_data(0, n)) #pylint: disable=protected-access + data.steps.append(self.steps[n]._get_data(0, data.audio_id)) #pylint: disable=protected-access + data.audio_id += 1 if st > t: ## We're done break ## Clean up the old steps - data.steps = [i for i in data.steps if i.start_time+i.actual_duration >= t] + data.steps = [i for i in data.steps if i.start_time+i.actual_duration >= t and i.step.index != -1] if not data.steps: return (), (), data @@ -151,10 +245,38 @@ class Chaser(Function): return tuple(lc.items()), ac, data def serialize(self): - ## TODO: Implement this - raise NotImplementedError("Not done yet") + e = et.Element(BXW+"function") + e.set("type", self.type) + e.set("id", str(self.id)) + e.set("name", self.name) + e.set("advance-mode", self.advance_mode) + for n, s in enumerate(self.steps): + se = s.serialize() + e.insert(n, se) + + return e @classmethod def deserialize(cls, w, e): - ## TODO: Implement this - raise NotImplementedError("Not done yet") + from .chaserstep import ChaserStep + + if e.tag != BXW+"function": + raise LoadError("Invalid function tag") + elif e.get("type") != CHASER: + raise LoadError("Load delegated to wrong class (this is a bug)") + + id_ = cls.int_or_none(e.get("id")) + if id_ is None: + raise LoadError("Function tag has invalid/missing ID") + + name = e.get("name") + advance_mode = e.get("advance-mode") + if advance_mode not in (LOOP, RANDOM, ONESHOT): + raise ValueError("Invalid advance mode") + + chaser = cls(w=w, id_=id_, name=name, advance_mode=advance_mode) + + for step in e: + ChaserStep.deserialize(w, step, chaser) + + return chaser diff --git a/blc2/functions/chaserstep.py b/blc2/functions/chaserstep.py index 9884cc6..005f76e 100644 --- a/blc2/functions/chaserstep.py +++ b/blc2/functions/chaserstep.py @@ -1,19 +1,25 @@ """Module for chaser steps.""" -from ..constants import CHASERSTEP, EXTERNAL, INHERIT, MANUAL, INFTY +import xml.etree.ElementTree as et + +from ..constants import CHASERSTEP, EXTERNAL, INHERIT, INFTY, MANUAL, BXW from .function import Function +from ..exceptions import LoadError class ChaserStep(Function): """Class representing a single chaser step.""" - type: CHASERSTEP + type = CHASERSTEP - def __init__(self, w: "Workspace", id_: int = None, name: str = None, + def __init__(self, chaser: "Chaser", id_: int = None, name: str = None, fade_in = 0, fade_out = 0, function: Function = None, index: int = None, - duration_mode=MANUAL): - super().__init__(w=w, id_=id_, name=name) + duration_mode = INHERIT, duration = 0): + if index is not None and index < -2: + raise ValueError("Step index must be nonnegative") + self.chaser = chaser + super().__init__(w=chaser.w, id_=id_, name=name) self._function = None self._duration_mode = duration_mode - self._duration = 0 + self._duration = duration self._actual_duration = 0 self._fade_out = fade_out self._fade_in = fade_in @@ -21,6 +27,12 @@ class ChaserStep(Function): self._index = index self._set_function(function, update=False) + try: + self.chaser.register_step(self) + except Exception as e: + self.w.function_deleted(self) + raise e + class ChaserStepData: """Data for a ChaserStep. @@ -42,21 +54,26 @@ class ChaserStep(Function): def copy(self): """Duplicate the data.""" return self.__class__(self.start_time, self.end_time, self.step, self.index, - self.data, self.audio_id) + self.data, self.audio_id, self.function) - def __init__(self, start_time, end_time, step, index, data, audio_id): + def __init__(self, start_time, end_time, step, index, data, audio_id, function): self.start_time = start_time self.end_time = end_time self.step = step self.index = index self.data = data self.audio_id = audio_id + self.function = function @property def index(self): """Return this step's index in the chaser.""" return self._index + @index.setter + def index(self, value): + self.chaser.move_step(self, value) + def _set_index(self, value): """Change this step's index. @@ -79,13 +96,15 @@ class ChaserStep(Function): self._fade_out_mode = EXTERNAL else: self._actual_duration = self._function.actual_duration - self._duration = self._function.duration + self._duration = self._function.actual_duration if self._function.fade_out_mode == EXTERNAL: - self.actual_duration += self._fade_out + self._actual_duration += self._fade_out + else: + self._duration = max(0, self._duration - self.fade_out) self._fade_out_mode = self._function.fade_out_mode else: ## Manual duration - self.actual_duration = self.duration + self._fade_out + self._actual_duration = self.duration + self._fade_out self._fade_out_mode = EXTERNAL if update: self.w.function_changed(self) @@ -144,6 +163,8 @@ class ChaserStep(Function): return self._function def _set_function(self, v, update=True): + if v is not None and v.type == CHASERSTEP: + raise ValueError("ChaserStep cannot be used as a ChaserStep's function") if v != self._function: if self._function is not None: ## Clear old callbacks self.w.delete_callbacks(self, self._function.id) @@ -151,7 +172,7 @@ class ChaserStep(Function): if v is not None: self.w.register_function_change_callback(v.id, self._function_changed, self) self.w.register_function_delete_callback(v.id, self._function_deleted, self) - self.fade_out_mode = v.fade_out_mode + self._fade_out_mode = v.fade_out_mode self._recalculate_duration(update=update) @function.setter @@ -162,6 +183,15 @@ class ChaserStep(Function): def duration(self): return self._duration + @duration.setter + def duration(self, value): + if self._duration_mode != MANUAL: + raise AttributeError("Can't set duration in inherit mode") + elif value < 0: + raise ValueError("Duration must be nonnegative") + self._duration = value + self._recalculate_duration() + @property def actual_duration(self): return self._actual_duration @@ -171,7 +201,7 @@ class ChaserStep(Function): def _get_data(self, start_time, audio_id): fd = None if self._function is None else self._function.get_data() - data = self.ChaserStepData(start_time, INFTY, self, self._index, fd, audio_id) + data = self.ChaserStepData(start_time, INFTY, self, self._index, fd, audio_id, self._function) return data def copy_data(self, data): @@ -190,33 +220,71 @@ class ChaserStep(Function): return frozenset() def render(self, t, data=None): + if data.function != self._function: + data.function = self._function + data.data = self._function.get_data() if self._function is not None else None if self._function is None: return (), (), data if data is None: raise ValueError("Data cannot be None for ChaserStep") + if data.index != self._index: + data.index = self._index + + if t > min(self._actual_duration, data.end_time+self._fade_out): + return (), (), data + fade_start = min(data.end_time, self._duration) t -= data.start_time ## Compute lighting multiplier mul = 1 if t < self.fade_in: - mul *= t/self.fade_in - if t > fade_start and t <= data.actual_duration: - mul *= 1 - (t-fade_start)/self.fade_out + mul *= min(1, t/self.fade_in) + if fade_start < t <= data.actual_duration: + mul *= 1 - min(1, (t-fade_start)/self.fade_out) ## Render and fade cues lc, ac, data.data = self._function.render(t, data=data.data) lc = tuple(((c, int(v*mul)) for c, v in lc)) - ac = tuple(((hash((data.audio_id, aid)), fname, max(self.fade_out, fout), min(fade_start, fstart), min(fout, self.fade_out)) for aid, fname, fin, fstart, fout in ac)) + ac = tuple(((hash((data.audio_id, aid)), fname, st+data.start_time, max(self.fade_in, fin), min(fade_start, fstart), max(fout, self.fade_out)) for aid, fname, st, fin, fstart, fout in ac)) return lc, ac, data def serialize(self): - ## TODO: Implement this - raise NotImplementedError("Not done yet") + e = et.Element(BXW+"step") + e.set("id", str(self.id)) + e.set("name", self.name) + e.set("fade-in", str(self.fade_in)) + e.set("fade-out", str(self.fade_out)) + e.set("duration-mode", self.duration_mode) + if self.duration_mode == MANUAL: + e.set("duration", str(self.duration)) + if self._function is not None: + e.set("function", str(self._function.id)) + + return e @classmethod - def deserialize(cls, w, e): - ## TODO: Implement this - raise NotImplementedError("Not done yet") + def deserialize(cls, w, e, c): #pylint: disable=arguments-differ + if e.tag != BXW+"step": + raise LoadError("Invalid chaser step tag") + + id_ = cls.int_or_none(e.get("id")) + if id_ is None: + raise LoadError("Step data has invalid/missing ID") + + name = e.get("name") + fade_in = e.get("fade-in") + fade_in = int(fade_in) if fade_in is not None else 0 + fade_out = e.get("fade-out") + fade_out = int(fade_out) if fade_out is not None else 0 + duration_mode = e.get("duration-mode") + if duration_mode not in (MANUAL, INHERIT): + raise LoadError("Invalid duration mode") + duration = int(e.get("duration")) if duration_mode == MANUAL else 0 + function = int(e.get("function")) + + return cls(c, id_=id_, name=name, fade_in=fade_in, fade_out=fade_out, + function=w.functions[function], duration_mode=duration_mode, + duration=duration) diff --git a/blc2/functions/function.py b/blc2/functions/function.py index 79d3b2b..21632a7 100644 --- a/blc2/functions/function.py +++ b/blc2/functions/function.py @@ -7,7 +7,7 @@ from abc import ABCMeta, abstractmethod, abstractproperty from typing import Set, Any from ..topology import Fixture -from ..constants import INFTY, EXTERNAL, FUNCTION +from ..constants import EXTERNAL, FUNCTION from ..interfaces import XMLSerializable class Function(XMLSerializable, metaclass=ABCMeta): @@ -26,14 +26,14 @@ class Function(XMLSerializable, metaclass=ABCMeta): It is an error to change Function.type or Function.fade_out_mode in user code. These values are public for informational purposes. """ - type: FUNCTION - fade_out_mode: EXTERNAL + type = FUNCTION + fade_out_mode = EXTERNAL def __init__(self, w: "Workspace", id_: int = None, name: str = None): self.w = w self._id = id_ if id_ is not None else w.next_function_id self._name = name if name else "%s %s" % (self.type, self.id) - + self.w.register_function(self) @property @@ -112,6 +112,10 @@ class Function(XMLSerializable, metaclass=ABCMeta): lights, sound, data = f.render(t, data) + Once a specific ``data`` instance has been used to render at a time, it must not be + used to render at a previous time: this is undefined behaviour and will break at + least Chasers. + :param t: the time to render at, in milliseconds :param data: the function data to use """ diff --git a/blc2/functions/scene.py b/blc2/functions/scene.py index c786287..239df3c 100644 --- a/blc2/functions/scene.py +++ b/blc2/functions/scene.py @@ -126,7 +126,7 @@ class Scene(Function): e.set("type", self.type) e.set("id", str(self.id)) e.set("name", self.name) - for c, v in self.values: + for c, v in self.values.items(): ce = et.SubElement(e, BXW+"value") ce.set("fixture", str(c.f.id)) ce.set("channel", str(c.id)) diff --git a/blc2/workspace.py b/blc2/workspace.py index 55757d3..83dba5b 100644 --- a/blc2/workspace.py +++ b/blc2/workspace.py @@ -8,7 +8,7 @@ import json import subprocess as subp import xml.etree.ElementTree as et -from .constants import AUDIO, SCENE, BXW +from .constants import AUDIO, SCENE, BXW, CHASER, CHASERSTEP from .functions.function import Function from .exceptions import LoadError from .interfaces import XMLSerializable @@ -177,7 +177,7 @@ class Workspace(XMLSerializable): def topology_changed(self): """Call when a topology change has been made.""" - for f in self._topology_callbacks.values(): + for _, f in self._topology_callbacks.values(): f(self) def function_changed(self, f: Function): @@ -187,7 +187,7 @@ class Workspace(XMLSerializable): """ if f.id not in self._change_callbacks: return - for callback in self._change_callbacks[f.id]: + for _, callback in self._change_callbacks[f.id]: callback(f) def function_deleted(self, f: Function): @@ -197,7 +197,7 @@ class Workspace(XMLSerializable): """ if f.id not in self._change_callbacks: return - for callback in self._delete_callbacks[f.id]: + for _, callback in self._delete_callbacks[f.id]: callback(f) self.delete_callbacks(f) @@ -217,6 +217,7 @@ class Workspace(XMLSerializable): from .topology import Fixture from .functions.scene import Scene from .functions.audio import Audio + from .functions.chaser import Chaser if e.tag != BXW+"workspace": raise LoadError("Root tag must be workspace") @@ -239,12 +240,15 @@ class Workspace(XMLSerializable): Fixture.deserialize(w, fixture) ## Finally, load the functions + ## TODO: Find a working order before trying to load for function in functions: type_ = function.get("type") if type_ == AUDIO: Audio.deserialize(w, function) elif type_ == SCENE: Scene.deserialize(w, function) + elif type_ == CHASER: + Chaser.deserialize(w, function) else: raise LoadError("Unknown function type \"%s\"" % type_) @@ -264,13 +268,34 @@ class Workspace(XMLSerializable): fixtures.insert(n, fe) functions = et.SubElement(root, BXW+"functions") - for n, function in enumerate(self.functions.values()): + f_order = [] + done = set() + all_f = list(self.functions.values()) + while all_f: + f = all_f.pop(0) + if f.type == CHASERSTEP: + continue + elif f.type in (AUDIO, SCENE): + f_order.append(f) + done.add(f.id) + elif f.type == CHASER: + for step in f.steps: + if step.function is not None and step.function.id not in done: + break + else: + f_order.append(f) + done.add(f.id) + continue + all_f.append(f) + else: + raise ValueError("Unknown function type "+f.type) + + for n, function in enumerate(f_order): fe = function.serialize() functions.insert(n, fe) return root - def save(self, filename): """Save the workspace to a file.""" et.register_namespace("", BXW.strip("{}")) diff --git a/examples/workspace.xml b/examples/workspace.xml index a2be9e0..c0161fb 100644 --- a/examples/workspace.xml +++ b/examples/workspace.xml @@ -35,8 +35,8 @@ test.wav - - + + diff --git a/tests/test_functions_chaserstep.py b/tests/test_functions_chaserstep.py new file mode 100644 index 0000000..134b893 --- /dev/null +++ b/tests/test_functions_chaserstep.py @@ -0,0 +1,124 @@ +import datetime as dt + +import pytest + +from blc2.functions.chaserstep import ChaserStep +from blc2.topology import Fixture +from blc2.functions.scene import Scene +from blc2.functions.audio import Audio +from blc2.workspace import Workspace +from blc2.constants import INHERIT, MANUAL, EXTERNAL, INTERNAL, INFTY + +class DummyChaser: + def register_step(self, *args, **kwargs): + return + + def __init__(self, w): + self.w = w + +@pytest.fixture +def cw(): + w = Workspace("", "", "", dt.datetime.now()) + f = Fixture(w=w, id_=0, channel_count=1) + c0, = f.channels + f2 = Fixture(w=w, id_=1, channel_count=4) + Scene(w=w, id_=0, values={c0: 255}) + Scene(w=w, id_=1, values={c: 255-i for i, c in enumerate(f2.channels)}) + Audio(w=w, id_=2, filename="tests/silence.m4a") + + return w + +def test_chaserstep(cw): + c = DummyChaser(cw) + s0 = cw.functions[0] + s1 = cw.functions[1] + a = cw.functions[2] + c0, = cw.fixtures[0].channels + + ## Test how it handles inherit + cs1 = ChaserStep(c, function=s0, duration_mode=INHERIT) + assert cs1.duration == INFTY + assert cs1.actual_duration == INFTY + assert cs1.fade_out_mode == s0.fade_out_mode + assert cs1.scope == s0.scope + assert cs1.audio_scope == s0.audio_scope + + cs1.fade_in = 1000 + cs1.fade_out = 1000 + assert cs1.duration == INFTY + assert cs1.actual_duration == INFTY + + data = cs1._get_data(0, 0) + lc, ac, data = cs1.render(500, data) + assert not ac + assert lc == ((c0, 127),) + + lc, ac, data = cs1.render(1000, data) + assert not ac + assert lc == ((c0, 255),) + + data.end_time = 1500 + lc, ac, data = cs1.render(2000, data) + assert not ac + assert lc == ((c0, 127),) + + lc, ac, data = cs1.render(2501, data) + assert not ac + assert not lc + + ## Test how it handles manual mode + cs1.duration_mode = MANUAL + cs1.duration = 1500 + assert cs1.duration == 1500 + assert cs1.actual_duration == 2500 + + data = cs1._get_data(0, 0) + lc, ac, data = cs1.render(500, data) + assert not ac + assert lc == ((c0, 127),) + + lc, ac, data = cs1.render(1000, data) + assert not ac + assert lc == ((c0, 255),) + + lc, ac, data = cs1.render(2000, data) + assert not ac + assert lc == ((c0, 127),) + + lc, ac, data = cs1.render(2501, data) + assert not ac + assert not lc + + ## Test how it handles inherit and a function change + cs1.duration_mode = INHERIT + assert cs1.duration == INFTY + assert cs1.actual_duration == INFTY + + data = cs1._get_data(0, 0) + + cs1.fade_out = 0 + cs1.fade_in = 0 + cs1.function = a + if a.actual_duration != 3024: + raise ValueError("silence.m4a is wrong duration, fix the tests") + assert cs1.audio_scope == a.audio_scope + assert cs1.scope == a.scope + assert cs1.duration == a.actual_duration + assert cs1.actual_duration == a.actual_duration + cs1.fade_out = 1000 + cs1.fade_in = 1000 + assert cs1.duration == a.actual_duration-1000 + + lc, ac, data = cs1.render(500, data) + assert not lc + assert len(ac) == 1 + assert ac[0][1:] == ("tests/silence.m4a", 0, 1000, 2024, 1000) + a.fade_out = 2000 + lc, ac, data = cs1.render(501, data) + assert not lc + assert len(ac) == 1 + assert ac[0][1:] == ("tests/silence.m4a", 0, 1000, 1024, 2000) + + lc, ac, data = cs1.render(3025, data) + assert not lc + assert not ac -- cgit v1.2.3