diff options
| author | Ben Connors <benconnors@outlook.com> | 2019-09-26 21:42:14 -0400 | 
|---|---|---|
| committer | Ben Connors <benconnors@outlook.com> | 2019-09-26 21:42:14 -0400 | 
| commit | dfe20c0430c7d58b57c44026102cf8b3c52ac1b3 (patch) | |
| tree | 1c0ad08b16c0183b4bb3c7ebff358950e3971534 | |
| parent | cefc580a2f38f14c0245c9d6a5acbaa67feaf8d4 (diff) | |
Lots of stuff
- Add tests for chaser steps
- Finish preliminary implementation of chasers
- Implement (de)serialization on chasers and steps
- Various bugfixes from testing
| -rw-r--r-- | blc2/functions/chaser.py | 148 | ||||
| -rw-r--r-- | blc2/functions/chaserstep.py | 112 | ||||
| -rw-r--r-- | blc2/functions/function.py | 12 | ||||
| -rw-r--r-- | blc2/functions/scene.py | 2 | ||||
| -rw-r--r-- | blc2/workspace.py | 37 | ||||
| -rw-r--r-- | examples/workspace.xml | 4 | ||||
| -rw-r--r-- | tests/test_functions_chaserstep.py | 124 | 
7 files changed, 391 insertions, 48 deletions
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.""" @@ -29,6 +29,17 @@ class Chaser(Function):              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 @@              <filename>test.wav</filename>          </function> -        <function type="Chaser" id="2" name="Chaser 1"> -            <step id="0" name="Step 1" fade-in="0" fade-out="0" duration-mode="Manual" duration="123" function="0"/> +        <function type="Chaser" id="2" name="Chaser 1" advance-mode="Loop"> +            <step id="3" name="Step 1" fade-in="0" fade-out="0" duration-mode="Manual" duration="123" function="0"/>          </function>      </functions>  </workspace> 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  | 
