Repository: ManiacalLabs/BiblioPixel
Pull request #1005 (2018-10-12 09:24:43)

Preparation for "envelopes and LFOs" branch
This is from my other working branch, "envelopes and LFOs".

These commits are also over a month old, so I extracted out the fully tested parts so I have less work hanging out in random branches (which I find stressful)


Patch:
diff --git a/bibliopixel/animation/animation.py b/bibliopixel/animation/animation.py
index 18a1521..6e3388a 100644
--- a/bibliopixel/animation/animation.py
+++ b/bibliopixel/animation/animation.py
@@ -44,6 +44,7 @@ class Animation(object):
         self.project = None
         self.runner = None
         self.time = time.time
+        self.preframe_callbacks = []
 
     def set_project(self, project):
         self.project = project
@@ -95,16 +96,17 @@ class Animation(object):
         if clean_layout:
             self.layout.cleanup()
 
-    def preframe_callback(self):
+    def add_preframe_callback(self, callback):
         """
-        preframe_callback is called right before the start of a frame rendering
-        pass.
+        The preframe_callbacks are called right before the start of a
+        frame rendering pass.
 
-        The ``Project`` writes over this method, for the top-level animation
-        only, in order to drain its edit_queue at a moment where no rendering
-        is happening to avoid race conditions.
+        To avoid race conditions when editing values, the ``Project``
+        adds a callback here for the top-level animation, to drain the
+        edit_queue at a moment where no rendering is
+        happening.
         """
-        pass
+        self.preframe_callbacks.append(callback)
 
     def start(self):
         self.threading.start()
@@ -144,8 +146,8 @@ class Animation(object):
     def _run_one_frame(self):
         timestamps = [self.time()]
 
-        self.preframe_callback()
-        timestamps = [self.time()]
+        for cb in self.preframe_callbacks:
+            cb()
 
         self.step(self.runner.amt)
         timestamps.append(self.time())
diff --git a/bibliopixel/control/envelope/__init__.py b/bibliopixel/control/envelope/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/bibliopixel/control/envelope/envelope.py b/bibliopixel/control/envelope/envelope.py
new file mode 100644
index 0000000..e9e396d
--- /dev/null
+++ b/bibliopixel/control/envelope/envelope.py
@@ -0,0 +1,119 @@
+import math, random
+from ... project import importer
+from . import segments as _segments
+
+ENVELOPE_PATH = 'bibliopixel.control.envelope.functions'
+
+
+class Envelope:
+    def __init__(self, enabled=True, base_value=0, period=None, phase=0,
+                 offset=0, scale=1, loops=0, symmetry=0.5, reverse=False,
+                 serpentine=False, power=1, frequency=None, duty_cycle=0.5):
+        self.enabled = enabled
+        self.base_value = base_value
+        self.phase = phase
+        self.offset = offset
+        self.scale = scale
+        self.loops = loops
+        self.symmetry = symmetry
+        self.duty_cycle = duty_cycle
+        self.reverse = reverse
+        self.serpentine = serpentine
+        self.power = power
+        if period:
+            self.period = period
+        elif frequency:
+            self.frequency = frequency
+        else:
+            self.period = 1
+
+    @property
+    def frequency(self):
+        return 1 / self.period
+
+    @frequency.setter
+    def frequency(self, f):
+        self.period = 1 / f
+
+    def _call(self, x):
+        raise NotImplementedError
+
+    def __call__(self, delta_time):
+        if self.enabled:
+            loop = delta_time / self.period
+            if not (self.loops and loop >= self.loops):
+                x = ((delta_time + self.phase) / self.period) % 1
+                if self.reverse:
+                    x = 1 - x
+                if self.serpentine and loop % 2:
+                    x = 1 - x
+                if self.power != 1:
+                    x = x ** self.power
+                x = _apply_skew(x, 1 - self.duty_cycle)
+                x = self._call(x)
+                x = _apply_skew(x, self.symmetry)
+                return x * self.scale + self.offset
+
+        return self.base_value
+
+
+class Linear(Envelope):
+    def _call(self, x):
+        return x
+
+
+class Sine(Envelope):
+    def _call(self, x):
+        return math.sin(x * 2 * math.pi)
+
+
+class Triangular(Envelope):
+    def _call(self, x):
+        return 2 * x if x < 0.5 else 2 - 2 * x
+
+
+class Square(Envelope):
+    def _call(self, x):
+        return 0 if x < 0.5 else 1
+
+
+class Random(Envelope):
+    def _call(self, x):
+        return random.random()
+
+
+class Gaussian(Envelope):
+    def __init__(self, mean=0.5, stdev=0.25, **kwds):
+        super().__init__(**kwds)
+        self.stdev = stdev
+        self.mean = mean
+
+    def _call(self, x):
+        return random.gauss(self.mean, self.stdev)
+
+
+class Segments(Envelope):
+    def __init__(self, segments=(), period=None, **kwds):
+        """
+        If period is None, then it is set to be the length of the
+        segments.
+
+        Each segment is either a single number, indicating a level, or a pair of
+        numbers, indicating a level and a time.  Levels without a time are
+        assigned the average delay.
+        """
+        self.segments = _segments.Segments(segments)
+        period = period or self.segments.total_time
+        super().__init__(period=period, **kwds)
+
+    def _call(self, x):
+        return self.segments(x * self.segments.total_time, self.base_value)
+
+
+def _apply_skew(x, skew):
+    if x == skew:
+        return 0.5
+    elif x < skew:
+        return 0.5 * x / skew
+    else:
+        return 0.5 + 0.5 * (x - skew) / (1 - skew)
diff --git a/bibliopixel/control/envelope/segments.py b/bibliopixel/control/envelope/segments.py
new file mode 100644
index 0000000..d1a42da
--- /dev/null
+++ b/bibliopixel/control/envelope/segments.py
@@ -0,0 +1,42 @@
+import statistics
+
+
+class Segments(list):
+    """
+    A list of [level, time] pairs.
+    """
+    def __init__(self, segments=(), length=None):
+        super().__init__()
+        self.base_value = 0
+        for segment in segments:
+            try:
+                level, time = segment
+            except TypeError:
+                level, time = segment, None
+            self.append([level, time])
+
+        times = [t for s, t in self if t is not None]
+        if times:
+            mean = sum(times) / len(times)
+        else:
+            mean = (length or 1) / max(1, len(self))
+
+        for segment in self:
+            if segment[1] is None:
+                segment[1] = mean
+
+        self.total_time = sum(t for l, t in self)
+
+    def __call__(self, time, base_value=0):
+        elapsed_time = 0
+        level = base_value
+        for l, t in self:
+            segment_end_time = elapsed_time + t
+            if time < segment_end_time:
+                delta_t = time - elapsed_time
+                return level + (l - level) * delta_t / t
+
+            elapsed_time = segment_end_time
+            level = l
+
+        return level
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 5b6d065..f26108b 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -61,7 +61,7 @@ class Project:
 
         eq = edit_queue.EditQueue(maxsize=edit_queue_maxsize)
         self.layout.edit_queue = self.animation.edit_queue = eq
-        self.animation.preframe_callback = eq.get_and_run_edits
+        self.animation.add_preframe_callback(eq.get_and_run_edits)
 
         # Unfortunately, the whole animation cycle is controlled by methods on
         # the topmost animation - but we need to get called back at a certain
diff --git a/bibliopixel/util/permutation.py b/bibliopixel/util/permutation.py
new file mode 100644
index 0000000..08acd30
--- /dev/null
+++ b/bibliopixel/util/permutation.py
@@ -0,0 +1,36 @@
+import operator
+
+
+def advance_permutation(a, increasing=True, forward=True):
+    """
+    Advance a list of unique, ordered elements in-place, lexicographically
+    increasing or backward, by rightmost or leftmost digit.
+
+    Returns False if the permutation wrapped around - i.e. went from
+    lexicographically greatest to least, and True in all other cases.
+
+    If the length of the list is N, then this function will repeat values after
+    N! steps, and will return False exactly once.
+
+    See also https://stackoverflow.com/a/34325140/43839
+    """
+
+    if not forward:
+        a.reverse()
+
+    cmp = operator.lt if increasing else operator.gt
+    try:
+        i = next(i for i in reversed(range(len(a) - 1)) if cmp(a[i], a[i + 1]))
+        j = next(j for j in reversed(range(i + 1, len(a))) if cmp(a[i], a[j]))
+    except StopIteration:
+        # This is the lexicographically last permutation.
+        if forward:
+            a.reverse()
+        return False
+
+    a[i], a[j] = a[j], a[i]
+    a[i + 1:] = reversed(a[i + 1:])
+    if not forward:
+        a.reverse()
+
+    return True


Test patch:
diff --git a/test/bibliopixel/control/envelope/__init__.py b/test/bibliopixel/control/envelope/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/bibliopixel/control/envelope/envelope_test.py b/test/bibliopixel/control/envelope/envelope_test.py
new file mode 100644
index 0000000..1c1a1cc
--- /dev/null
+++ b/test/bibliopixel/control/envelope/envelope_test.py
@@ -0,0 +1,113 @@
+import math, statistics, unittest
+from fractions import Fraction
+from bibliopixel.control.envelope import envelope
+from test.bibliopixel.control.envelope.segments_test import function_tester
+
+
+class EnvelopeTest(unittest.TestCase):
+    def test_identity(self):
+        env = envelope.Linear()
+        expected = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0]
+        function_tester(env, self, expected)
+
+    def test_disable(self):
+        env = envelope.Linear(base_value=0.5, enabled=False)
+        expected = [0.5] * 11
+        function_tester(env, self, expected)
+
+    def test_offset_and_scale(self):
+        env = envelope.Linear(offset=0.05, scale=2)
+        expected = [0.05, 0.25, 0.45, 0.65, 0.85,
+                    1.05, 1.25, 1.45, 1.65, 1.85, 0.05]
+        function_tester(env, self, expected)
+
+    def test_period(self):
+        env = envelope.Linear(period=2)
+        expected = [i / 20 for i in range(20)] + [0]
+        function_tester(env, self, expected, 10)
+
+    def test_symmetry(self):
+        env = envelope.Linear(symmetry=Fraction(1) / 3)
+        expected = [0 / 32, 6 / 32, 12 / 32, 17 / 32, 20 / 32,
+                    23 / 32, 26 / 32, 29 / 32, 0 / 32]
+        function_tester(env, self, expected)
+
+    def test_sine(self):
+        env = envelope.Sine()
+        expected = [math.sin(i * math.pi / 5) for i in range(11)]
+        function_tester(env, self, expected)
+
+    def test_square(self):
+        env = envelope.Square()
+        expected = [0, 0, 0, 0, 1, 1, 1, 1, 0]
+        function_tester(env, self, expected)
+
+        env = envelope.Square(duty_cycle=Fraction(1, 6))
+        expected = [0, 0, 0, 0, 0, 0, 0, 1, 0]
+        function_tester(env, self, expected)
+
+        env = envelope.Square(duty_cycle=Fraction(5, 6))
+        expected = [0, 0, 1, 1, 1, 1, 1, 1, 0]
+        function_tester(env, self, expected)
+
+    def test_triangular(self):
+        env = envelope.Triangular()
+        expected = [0, 0.25, 0.5, 0.75]
+        expected += [1] + expected[::-1]
+        function_tester(env, self, expected)
+
+    def _random(self, env, min_mean, max_mean, min_stdev, max_stdev,
+                test_count=3, sample_count=300):
+        mmin, smin, mmax, smax = 100, 100, 0, 0
+        for i in range(test_count):
+            values = [env(0) for i in range(sample_count)]
+            mean, stdev = statistics.mean(values), statistics.stdev(values)
+            mmax = max(mmax, mean)
+            mmin = min(mmin, mean)
+            smax = max(smax, stdev)
+            smin = min(smin, stdev)
+
+        self.assertGreater(mmin, min_mean)
+        self.assertLess(mmax, max_mean)
+        self.assertGreater(smin, min_stdev)
+        self.assertLess(smax, max_stdev)
+
+        return mmin, mmax, smin, smax
+
+    def test_random(self):
+        self._random(envelope.Random(), 0.35, 0.65, 0.20, 0.35)
+
+    def test_gaussian(self):
+        self._random(envelope.Gaussian(), 0.4, 0.6, 0.15, 0.35)
+        self._random(envelope.Gaussian(stdev=0.1), 0.45, 0.55, 0.08, 0.12)
+        self._random(envelope.Gaussian(mean=1.0), 0.9, 1.1, 0.20, 0.30)
+
+    def test_empty_segments(self):
+        env = envelope.Segments()
+
+        expected = []
+        function_tester(env, self, expected)
+
+        expected = [0, 0, 0, 0]
+        function_tester(env, self, expected)
+
+    def test_simple_segments(self):
+        env = envelope.Segments([1, 2, 3, 4])
+        self.assertEquals(env.period, 1)
+
+        expected = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 0]
+        function_tester(env, self, expected)
+
+    def test_simple_segments2(self):
+        env = envelope.Segments([1, 1.5, 3, 7])
+        self.assertEquals(env.period, 1)
+
+        expected = [0, 0.5, 1, 1.25, 1.5, 2.25, 3, 5, 0]
+        function_tester(env, self, expected)
+
+    def test_decreasing_segments(self):
+        env = envelope.Segments([1, 3, 2, 4])
+        self.assertEquals(env.period, 1)
+
+        expected = [0, 0.5, 1, 2, 3, 2.5, 2, 3, 0]
+        function_tester(env, self, expected)
diff --git a/test/bibliopixel/control/envelope/segments_test.py b/test/bibliopixel/control/envelope/segments_test.py
new file mode 100644
index 0000000..aaa73be
--- /dev/null
+++ b/test/bibliopixel/control/envelope/segments_test.py
@@ -0,0 +1,58 @@
+import unittest
+from fractions import Fraction
+from bibliopixel.util import log
+from bibliopixel.control.envelope import segments
+
+
+class SegmentsTest(unittest.TestCase):
+    def test_empty_segments(self):
+        segs = segments.Segments()
+        expected = []
+        function_tester(segs, self, expected)
+
+        expected = [0, 0, 0, 0]
+        function_tester(segs, self, expected)
+
+    def test_simple_segments(self):
+        segs = segments.Segments([1, 2, 3, 4])
+        self.assertEquals(segs.total_time, 1)
+
+        expected = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]
+        function_tester(segs, self, expected)
+
+    def test_simple_segments2(self):
+        segs = segments.Segments([1, 1.5, 3, 7])
+        self.assertEquals(segs.total_time, 1)
+
+        expected = [0, 0.5, 1, 1.25, 1.5, 2.25, 3, 5, 7]
+        function_tester(segs, self, expected)
+
+    def test_decreasing_segments(self):
+        segs = segments.Segments([1, 3, 2, 4])
+        self.assertEquals(segs.total_time, 1)
+
+        expected = [0, 0.5, 1, 2, 3, 2.5, 2, 3, 4]
+        function_tester(segs, self, expected)
+
+    def test_timed_segments(self):
+        segs = segments.Segments([(1, 1), (2, 1), (5, 0.5), (11, 0.5)])
+        self.assertEquals(segs.total_time, 3)
+        log.printer(segs)
+        self.assertEquals(segs(0), 0)
+        self.assertEquals(segs(0.5), 0.5)
+        self.assertEquals(segs(1), 1)
+        self.assertEquals(segs(2), 2)
+        self.assertEquals(segs(2.25), 3.5)
+        self.assertEquals(segs(2.5), 5)
+        self.assertEquals(segs(2.75), 8)
+        self.assertEquals(segs(3), 11)
+
+
+def function_tester(function, test, expected, frac=0):
+    n = len(expected)
+    frac = frac or (n - 1)
+    actual = [function(Fraction(i, frac)) for i in range(n)]
+
+    actual = [round(i, 12) for i in actual]
+    expected = [round(i, 12) for i in expected]
+    test.assertEquals(expected, actual)
diff --git a/test/bibliopixel/util/permutation_test.py b/test/bibliopixel/util/permutation_test.py
new file mode 100644
index 0000000..5449a03
--- /dev/null
+++ b/test/bibliopixel/util/permutation_test.py
@@ -0,0 +1,53 @@
+import unittest
+from bibliopixel.util.permutation import advance_permutation
+
+
+class PermutationTest(unittest.TestCase):
+    def test_permutation(self):
+        a = [0, 1, 2, 3, 4]
+        self.assertTrue(advance_permutation(a))
+        self.assertEqual([0, 1, 2, 4, 3], a)
+
+        a = [3, 0, 4, 2, 1]
+        self.assertTrue(advance_permutation(a))
+        self.assertEqual([3, 1, 0, 2, 4], a)
+
+        a = [4, 3, 2, 1, 0]
+        self.assertFalse(advance_permutation(a))
+        self.assertEqual([0, 1, 2, 3, 4], a)
+
+    def test_reverse(self):
+        x = [0, 1, 2, 3, 4], [3, 0, 4, 2, 1], [1, 2, 3, 4, 0], [4, 3, 2, 1, 0]
+        for a in x:
+            b = list(a)
+            advance_permutation(b)
+            advance_permutation(b, increasing=False)
+            self.assertEqual(a, b)
+
+            b = list(a)
+            advance_permutation(b, increasing=False)
+            advance_permutation(b)
+            self.assertEqual(a, b)
+
+            b = list(a)
+            advance_permutation(b, increasing=False, forward=False)
+            advance_permutation(b, forward=False)
+            self.assertEqual(a, b)
+
+            b = list(a)
+            advance_permutation(b, forward=False)
+            advance_permutation(b, increasing=False, forward=False)
+            self.assertEqual(a, b)
+
+    def test_right_to_left(self):
+        a = [0, 1, 2, 3, 4]
+        self.assertFalse(advance_permutation(a, forward=False))
+        self.assertEqual([4, 3, 2, 1, 0], a)
+
+        a = [3, 0, 4, 2, 1]
+        self.assertTrue(advance_permutation(a, forward=False))
+        self.assertEqual([0, 3, 4, 2, 1], a)
+
+        a = [4, 3, 2, 1, 0]
+        self.assertTrue(advance_permutation(a, forward=False))
+        self.assertEqual([3, 4, 2, 1, 0], a)