Repository: ManiacalLabs/BiblioPixel
Pull request #712 (2018-03-29 13:56:00)

Make Layouts cloneable and other final tweaks to controllers.
I am pleased to announced that I have reached my holy grail, fading backwards and forwards between animations with a MIDI controller, and this is the pull request with the last details.

I think it's particularly slick.  On the Mac at least, you can plug in your controller that sends continuous controllers 0, 1, 2, 3 (which is basically all of them out of the box) on any channel, MIDI device etc

    pip install mido  # one time, or we could add it as a dependency
    bp projects/23-midi-control.json

and you're off - it autodetects you have just this MIDI device, etc so no configuration necessary.

I'm going to have to make a movie of this.  :-D

adammhaile: DO IT!

On Thu, Mar 29, 2018 at 9:56 AM, Tom Swirly <notifications@github.com>
wrote:

> I am pleased to announced that I have reached my holy grail, fading
> backwards and forwards between animations with a MIDI controller, and this
> is the pull request with the last details.
>
> I think it's particularly slick. On the Mac at least, you can plug in your
> controller that sends continuous controllers 0, 1, 2, 3 (which is basically
> all of them out of the box) on any channel, MIDI device etc
>
> pip install mido  # one time, or we could add it as a dependency
> bp projects/23-midi-control.json
>
> and you're off - it autodetects you have just this MIDI device, etc so no
> configuration necessary.
>
> I'm going to have to make a movie of this. :-D
> ------------------------------
> You can view, comment on, or merge this pull request online at:
>
>   https://github.com/ManiacalLabs/BiblioPixel/pull/712
> Commit Summary
>
>    - Improve error messages in midi.py
>    - Fix Control._msg_to_str for non-string message fields
>    - Copy `levels` up into the Mixer animation for addressing convenience
>    - `color_list.Mixer` now enforces one level per source
>    - Make Layouts cloneable.
>
> File Changes
>
>    - *M* bibliopixel/animation/mixer.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-0>
>    (1)
>    - *M* bibliopixel/animation/parallel.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-1>
>    (7)
>    - *M* bibliopixel/control/control.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-2>
>    (2)
>    - *M* bibliopixel/control/midi.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-3>
>    (20)
>    - *M* bibliopixel/layout/circle.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-4>
>    (2)
>    - *M* bibliopixel/layout/cube.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-5>
>    (1)
>    - *M* bibliopixel/layout/layout.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-6>
>    (16)
>    - *M* bibliopixel/layout/matrix.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-7>
>    (3)
>    - *M* bibliopixel/layout/strip.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-8>
>    (1)
>    - *M* bibliopixel/util/color_list.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-9>
>    (9)
>    - *A* projects/23-midi-control.json
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-10>
>    (36)
>    - *M* test/bibliopixel/layout/layout_test.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-11>
>    (7)
>    - *M* test/bibliopixel/util/color_list_test.py
>    <https://github.com/ManiacalLabs/BiblioPixel/pull/712/files#diff-12>
>    (2)
>
> Patch Links:
>
>    - https://github.com/ManiacalLabs/BiblioPixel/pull/712.patch
>    - https://github.com/ManiacalLabs/BiblioPixel/pull/712.diff
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/ManiacalLabs/BiblioPixel/pull/712>, or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AA6a6nSl9M6o5c-saduDRsf_Qgx3Zawaks5tjOfwgaJpZM4TAYEL>
> .
>


Patch:
diff --git a/bibliopixel/animation/mixer.py b/bibliopixel/animation/mixer.py
index 4bc475f..0ac569d 100644
--- a/bibliopixel/animation/mixer.py
+++ b/bibliopixel/animation/mixer.py
@@ -12,6 +12,7 @@ class Mixer(parallel.Parallel):
             self.color_list,
             [a.color_list for a in self.animations],
             levels)
+        self.levels = self.mixer.levels
 
     def step(self, amt=1):
         super().step(amt)
diff --git a/bibliopixel/animation/parallel.py b/bibliopixel/animation/parallel.py
index 952512a..f66c01a 100644
--- a/bibliopixel/animation/parallel.py
+++ b/bibliopixel/animation/parallel.py
@@ -1,4 +1,3 @@
-import copy
 from . import collection
 
 
@@ -18,6 +17,10 @@ class Parallel(collection.Collection):
         # Give each animation a unique, mutable layout so they can
         # run independently.
         for i, a in enumerate(self.animations):
-            a.layout = copy.deepcopy(a.layout)
+            a.layout = a.layout.clone()
             if overlay and i:
                 a.preclear = False
+
+    def step(self, amt=1):
+        for a in self.animations:
+            a.step(amt)
diff --git a/bibliopixel/control/control.py b/bibliopixel/control/control.py
index 0f930ed..7537475 100644
--- a/bibliopixel/control/control.py
+++ b/bibliopixel/control/control.py
@@ -89,7 +89,7 @@ class Control:
     def _msg_to_str(self, msg):
         if msg is None:
             return '(None)'
-        return '.'.join(msg.values()) or '.'
+        return '.'.join(str(s) for s in msg.values()) or '.'
 
     def __bool__(self):
         return bool(self.routing or self.pre_routing)
diff --git a/bibliopixel/control/midi.py b/bibliopixel/control/midi.py
index 412bd0f..638d6c8 100644
--- a/bibliopixel/control/midi.py
+++ b/bibliopixel/control/midi.py
@@ -10,6 +10,16 @@ except:
     mido = None
     MESSAGE_TYPES = set()
 
+MIDO_ERROR = """You need to install mido.  Try
+    pip install mido
+"""
+
+
+MIDO_BACKEND_ERROR = """You have the wrong rtmidi installed.  Try
+    pip uninstall -y rtmidi
+    pip install -y python-rtmidi
+"""
+
 
 class Midi(control.ExtractedControl):
     EXTRACTOR = {
@@ -45,7 +55,15 @@ class Midi(control.ExtractedControl):
         self.use_note_off = use_note_off
 
     def __iter__(self):
-        ports = [mido.open_input(i) for i in mido.get_input_names()]
+        if not mido:
+            raise ValueError(MIDO_ERROR)
+        try:
+            input_names = mido.get_input_names()
+        except AttributeError as e:
+            e.args = (MIDO_ERROR,) + e.args
+            raise
+
+        ports = [mido.open_input(i) for i in input_names]
 
         if not ports:
             log.error('control.midi: no MIDI ports found')
diff --git a/bibliopixel/layout/circle.py b/bibliopixel/layout/circle.py
index 1574126..afd6eaf 100644
--- a/bibliopixel/layout/circle.py
+++ b/bibliopixel/layout/circle.py
@@ -6,6 +6,8 @@ from .. util import deprecated
 
 
 class Circle(Layout):
+    CLONE_ATTRS = Layout.CLONE_ATTRS + (
+        'rings', 'pixels_per', 'maxAngleDiff', 'rotation', 'reverse_angle')
 
     def __init__(self, drivers, rings=[], pixels_per=None,
                  maxAngleDiff=0, rotation=0, reverse_angle=False,
diff --git a/bibliopixel/layout/cube.py b/bibliopixel/layout/cube.py
index c3fccca..63eee74 100644
--- a/bibliopixel/layout/cube.py
+++ b/bibliopixel/layout/cube.py
@@ -7,6 +7,7 @@ from . geometry.cube import make_cube_coord_map, make_cube_coord_map_positions
 
 
 class Cube(Layout):
+    CLONE_ATTRS = Layout.CLONE_ATTRS + ('x', 'y', 'z')
 
     def __init__(self, drivers, x, y, z, coord_map=None,
                  threadedUpdate=False, brightness=255, **kwargs):
diff --git a/bibliopixel/layout/layout.py b/bibliopixel/layout/layout.py
index a1bf4e7..85a8fd4 100644
--- a/bibliopixel/layout/layout.py
+++ b/bibliopixel/layout/layout.py
@@ -1,4 +1,4 @@
-import time
+import copy, time
 from .. import util
 from .. project import attributes, data_maker, fields
 from .. util.threads.update_threading import UpdateThreading
@@ -20,6 +20,8 @@ class Layout(object):
     :ivar int numLEDs: Total number of pixels held by this layout instance
     """
 
+    CLONE_ATTRS = 'maker', 'brightness'
+
     pre_recursion = fields.default_converter
 
     @classmethod
@@ -80,6 +82,15 @@ class Layout(object):
         self.push_to_driver()
         self.threading.wait_for_update()
 
+    def clone(self):
+        """
+        Return an independent copy of this layout with a completely separate
+        color_list and no drivers.
+        """
+        args = {k: getattr(self, k) for k in self.CLONE_ATTRS}
+        args['color_list'] = copy.copy(self.color_list)
+        return self.__class__([], **args)
+
     @property
     def shape(self):
         """
@@ -195,8 +206,11 @@ class Layout(object):
 
 
 class MultiLayout(Layout):
+    CLONE_ATTRS = Layout.CLONE_ATTRS + ('gen_coord_map', 'coord_map')
+
     def __init__(self, *args, gen_coord_map=None, coord_map=None, **kwds):
         super().__init__(*args, **kwds)
+        self.gen_coord_map = gen_coord_map
         if gen_coord_map:
             if coord_map:
                 util.log.warning('Cannot set both coord_map and gen_coord_map')
diff --git a/bibliopixel/layout/matrix.py b/bibliopixel/layout/matrix.py
index 2e37f0c..b738ec3 100644
--- a/bibliopixel/layout/matrix.py
+++ b/bibliopixel/layout/matrix.py
@@ -15,6 +15,9 @@ It was rounded to %s degrees."""
 
 
 class Matrix(MultiLayout):
+    CLONE_ATTRS = MultiLayout.CLONE_ATTRS + (
+        'width', 'height', 'rotation', 'vert_flip', 'y_flip', 'serpentine',
+        'pixelSize')
 
     def __init__(self, drivers, width=0, height=0,
                  rotation=0, vert_flip=False, y_flip=False,
diff --git a/bibliopixel/layout/strip.py b/bibliopixel/layout/strip.py
index 60e86e1..b2a5f3f 100644
--- a/bibliopixel/layout/strip.py
+++ b/bibliopixel/layout/strip.py
@@ -5,6 +5,7 @@ from . geometry.strip import make_strip_coord_map_positions
 
 
 class Strip(MultiLayout):
+    CLONE_ATTRS = MultiLayout.CLONE_ATTRS + ('brightness', 'pixelWidth')
 
     def __init__(self, drivers, threadedUpdate=False,
                  brightness=255, pixelWidth=1, **kwargs):
diff --git a/bibliopixel/util/color_list.py b/bibliopixel/util/color_list.py
index c688945..e9f744e 100644
--- a/bibliopixel/util/color_list.py
+++ b/bibliopixel/util/color_list.py
@@ -78,8 +78,13 @@ class Mixer:
     def __init__(self, color_list, sources, levels=None):
         self.color_list = ColorList(color_list)
         self.sources = sources
-        self.levels = levels or []
-        self.clear = self.color_list.clear
+        self.levels = list(levels or [])
+        needed = len(self.sources) - len(self.levels)
+        if needed > 0:
+            self.levels.extend(0 for i in range(needed))
+
+    def clear(self):
+        self.color_list.clear()
 
     def mix(self, master=1):
         for source, level in zip(self.sources, self.levels):
diff --git a/projects/23-midi-control.json b/projects/23-midi-control.json
new file mode 100644
index 0000000..fbd6c01
--- /dev/null
+++ b/projects/23-midi-control.json
@@ -0,0 +1,36 @@
+{
+    "aliases": {
+        "bpa": "BiblioPixelAnimations.strip"
+    },
+
+    "controls": {
+        "typename": "midi",
+        "verbose": false,
+        "extractor": {
+            "accept": {"type": "control_change"}
+        },
+        "routing": {
+            "0": "animation.levels[0]",
+            "1": "animation.levels[1]",
+            "2": "animation.levels[2]",
+            "3": "animation.levels[3]"
+        }
+    },
+
+    "dimensions": [50],
+
+    "run": {
+        "fps": 30
+    },
+
+    "animation": {
+        "typename": "mixer",
+        "levels": [1, 0, 0, 0],
+        "animations": [
+            "@bpa.Rainbows.RainbowCycle",
+            "@bpa.Wave",
+            "@bpa.HalvesRainbow",
+            "@bpa.PartyMode"
+        ]
+    }
+}


Test patch:
diff --git a/test/bibliopixel/layout/layout_test.py b/test/bibliopixel/layout/layout_test.py
index 485e8e2..6a5029d 100644
--- a/test/bibliopixel/layout/layout_test.py
+++ b/test/bibliopixel/layout/layout_test.py
@@ -49,3 +49,10 @@ class LayoutTest(unittest.TestCase):
         strip.set_color_list(RED + GREEN, 2)
         expected = [BLACK, BLACK, RED, GREEN]
         self.assertEquals(expected, strip.color_list)
+
+    def test_clone(self):
+        strip = Strip(MockDriver(4))
+        clone = strip.clone()
+        clone.set(1, RED)
+        self.assertEquals(clone.get(1), RED)
+        self.assertEquals(strip.get(1), BLACK)
diff --git a/test/bibliopixel/util/color_list_test.py b/test/bibliopixel/util/color_list_test.py
index ccd7fce..f9cabe8 100644
--- a/test/bibliopixel/util/color_list_test.py
+++ b/test/bibliopixel/util/color_list_test.py
@@ -102,7 +102,7 @@ class ColorListTest(TestBase):
 
 class MixerTest(TestBase):
     def do_test(self, mixer, thirds):
-        self.assertEqual(mixer.levels, [])
+        self.assertEqual(mixer.levels, [0, 0, 0])
         self.assert_list_equal(mixer.color_list, COLORS1)
 
         mixer.mix()