It's always a good idea to get your software loaded onto your board before the build. That way you'll be able to test your solder joints at each step of the way, and you'll get instant gratification when you plug in the lights.

Getting the software loaded is a 3-step process:

  1. Install the operating system (CircuitPython) on the board
  2. Copy the required libraries into the /lib folder on the board
  3. Save the code.py file to the board

CircuitPython is a fairly new OS that's changing rapidly. New features are being added and bugs are being fixed all the time, so it's always best to get a fresh version of CircuitPython and the library files before coding.

Install CircuitPython

The Adafruit Feather Sense ships with CircuitPython, but let's go ahead and update it to the latest version. It's super easy with the circuitpython.org website. Follow the directions on the Feather Bluefruit Sense guide, or click the button below for a direct download link.

Download the file, plug your Feather Sense into your computer via the USB port, and double-click the reset button. You'll see a drive appear called FTHR840BOOT. Drag the .uf2 file you just downloaded onto this drive to install CircuitPython.

You'll know it worked if the FTHR840BOOT drive name changes to CIRCUITPY.

Adafruit Circuit Python Libraries

Download the CircuitPython library bundle per the Feather Sense guide instructions here. Unzip the files into a folder on your computer. Create a new folder on the CIRCUITPY drive and name it lib

Open the library download folder and find the following files. Copy them into the lib folder on your CIRCUITPY drive.

  • adafruit_bus_device (directory)
  • adafruit_lsm6ds.mpy
  • adafruit_register (directory)
  • neopixel.mpy

Upload Files

Click the link below to download the project zip – This contains the code. Upload the code.py file to the CIRCUITPY drive root (main) folder.

Check out the image above to see what your CIRCUITPY drive should look like when all the files are in place.

"""
Prop-Maker based LED Bullwhip
Adafruit invests time and resources providing this open source code.
Please support Adafruit and open source hardware by purchasing
products from Adafruit!
Written by Erin St Blaine & Limor Fried for Adafruit Industries
Copyright (c) 2019-2020 Adafruit Industries
Licensed under the MIT license.
All text above must be included in any redistribution.
"""

import time
import array
import math
import digitalio
import audiobusio
import board
import neopixel
import adafruit_lsm6ds

# CUSTOMISE COLORS HERE:
COLOR = (40, 3, 0)      # Default idle is blood orange
HIT_COLOR = (0, 250, 0)  # hit color is green
LIGHT_WAVE_COLOR = (200, 50, 200) # purple
DARK_COLOR = (0, 0, 0)
CRACK_COLOR = (250, 250, 250) #white


# CUSTOMISE IDLE PULSE SPEED HERE: 0 is fast, above 0 slows down
IDLE_PULSE_SPEED = 0  # Default is 0 seconds
SWING_BLAST_SPEED = 0.007

# CUSTOMISE BRIGHTNESS HERE: must be a number between 0 and 1
IDLE_PULSE_BRIGHTNESS_MIN = 0.1  # Default minimum idle pulse brightness
IDLE_PULSE_BRIGHTNESS_MAX = 0.5  # Default maximum idle pulse brightness

# CUSTOMISE SENSITIVITY HERE: smaller numbers = more sensitive to motion
HIT_THRESHOLD = 1150
SWING_THRESHOLD = 750
SOUND_THRESHOLD = 2000

# Set to the length in seconds for the animations
POWER_ON_DURATION = 1.7
LIGHT_WAVE_DURATION = 1
HIT_DURATION = 2
SWING_DURATION = 0
FADE_DURATION = 1
WHIP_CRACK_DURATION = 0.5

NUM_PIXELS = 60  # Number of pixels used in project
NEOPIXEL_PIN = board.D5
POWER_PIN = board.D10
ONSWITCH_PIN = board.A1

led = digitalio.DigitalInOut(ONSWITCH_PIN)
led.direction = digitalio.Direction.OUTPUT
led.value = True

enable = digitalio.DigitalInOut(POWER_PIN)
enable.direction = digitalio.Direction.OUTPUT
enable.value = False

strip = neopixel.NeoPixel(NEOPIXEL_PIN, NUM_PIXELS, brightness=1, auto_write=False)
strip.fill(0)  # NeoPixels off ASAP on startup
strip.show()

WAVE_FILE = None

i2c = board.I2C()

#Set up accelerometer & mic

sensor = adafruit_lsm6ds.LSM6DS33(i2c)
mic = audiobusio.PDMIn(board.MICROPHONE_CLOCK,
                       board.MICROPHONE_DATA,
                       sample_rate=16000,
                       bit_depth=16)

COLOR_IDLE = COLOR # 'idle' color is the default
COLOR_HIT = HIT_COLOR  # "hit" color is HIT_COLOR set above
COLOR_SWING = LIGHT_WAVE_COLOR  # "swing" color is HIT_COLOR set above
COLOR_ACTIVE = LIGHT_WAVE_COLOR


def mean(values):
    ''' Remove DC bias before computing RMS.'''
    return sum(values) / len(values)

def normalized_rms(values):
    ''' Normalize values'''
    minbuf = int(mean(values))
    samples_sum = sum(
        float(sample - minbuf) * (sample - minbuf)
        for sample in values
    )

    return math.sqrt(samples_sum / len(values))


samples = array.array('H', [0] * 160)
mic.record(samples, len(samples))

def mix(color_1, color_2, weight_2):
    """
    Blend between two colors with a given ratio.
    :param color_1:  first color, as an (r,g,b) tuple
    :param color_2:  second color, as an (r,g,b) tuple
    :param weight_2: Blend weight (ratio) of second color, 0.0 to 1.0
    :return (r,g,b) tuple, blended color
    """
    if weight_2 < 0.0:
        weight_2 = 0.0
    elif weight_2 > 1.0:
        weight_2 = 1.0
    weight_1 = 1.0 - weight_2
    return (int(color_1[0] * weight_1 + color_2[0] * weight_2),
            int(color_1[1] * weight_1 + color_2[1] * weight_2),
            int(color_1[2] * weight_1 + color_2[2] * weight_2))

def power_on(duration):
    """
    Animate NeoPixels for power on.
    :param duration: estimated duration of sound, in seconds (>0.0)
    """
    prev = 0
    start_time = time.monotonic()  # Save start time
    while True:
        elapsed = time.monotonic() - start_time  # Time spent
        if elapsed > duration:  # Past duration?
            break  # Stop animating
        animation_time = elapsed / duration  # Animation time, 0.0 to 1.0
        threshold = int(NUM_PIXELS * animation_time + 0.5)
        num = threshold - prev  # Number of pixels to light on this pass
        if num != 0:
            strip[prev:threshold] = [COLOR] * num
            strip.show()
            prev = threshold

def fade(duration):
    """
    Animate NeoPixels for hit/fade animation
    :param duration: estimated duration of sound, in seconds (>0.0)
    """
    prev = 0
    hit_time = time.monotonic()  # Save start time
    while True:
        elapsed = time.monotonic() - hit_time  # Time spent
        if elapsed > duration:  # Past duration?
            break  # Stop animating
        animation_time = elapsed / duration  # Animation time, 0.0 to 1.0
        threshold = int(NUM_PIXELS * animation_time + 0.5)
        num = threshold - prev  # Number of pixels to light on this pass
        if num != 0:
            blend = time.monotonic() - hit_time  # Time since triggered
            blend = abs(0.5 - blend) * 2.0  # ramp up, down
            strip.fill(mix(COLOR_ACTIVE, COLOR, blend))  # Fade from hit/swing to base color
            strip.show()

def light_wave(duration):
    """
    Animate NeoPixels for swing animatin
    :param duration: estimated duration of sound, in seconds (>0.0)
    """
    prev = 0
    swing_time = time.monotonic()  # Save start time
    while True:
        elapsed = time.monotonic() - swing_time  # Time spent
        if elapsed > duration:  # Past duration?
            break  # Stop animating
        animation_time = elapsed / duration  # Animation time, 0.0 to 1.0
        threshold = int(NUM_PIXELS * animation_time + 0.5)
        num = threshold - prev  # Number of pixels to light on this pass
        if num != 0:
            strip[prev:threshold] = [CRACK_COLOR] * num
            strip.show()
            prev = threshold

def whip_crack(duration):
    """
    Animate NeoPixels for swing animatin
    :param duration: estimated duration of sound, in seconds (>0.0)
    """
    prev = 0
    crack_time = time.monotonic()  # Save start time
    while True:
        elapsed = time.monotonic() - crack_time  # Time spent
        if elapsed > duration:  # Past duration?
            break  # Stop animating
        animation_time = elapsed / duration  # Animation time, 0.0 to 1.0
        threshold = int(NUM_PIXELS * animation_time + 0.5)
        num = threshold - prev  # Number of pixels to light on this pass
        if num != 0:
            strip.fill(CRACK_COLOR)
            strip.show()
            time.sleep(0.01)
            strip.fill(DARK_COLOR)
            strip.show()
            time.sleep(0.03)
            strip.fill(CRACK_COLOR)
            strip.show()
            time.sleep(0.02)
            strip.fill(DARK_COLOR)
            strip.show()
            time.sleep(0.005)
            strip.fill(CRACK_COLOR)
            strip.show()
            time.sleep(0.01)
            strip.fill(DARK_COLOR)
            strip.show()
            time.sleep(0.03)
            prev = threshold



MODE = 0  # Initial MODE = OFF

# Setup idle pulse
IDLE_BRIGHTNESS = IDLE_PULSE_BRIGHTNESS_MIN  # current brightness of idle pulse
IDLE_INCREMENT = 0.01  # Initial idle pulse direction

# Main loop
while True:
    if MODE == 0:  # If currently off...
        enable.value = True
        power_on(POWER_ON_DURATION)  # Power up!
        MODE = 1  # Idle MODE

        # Setup for idle pulse
        IDLE_BRIGHTNESS = IDLE_PULSE_BRIGHTNESS_MIN
        IDLE_INCREMENT = 0.01
        strip.fill([int(c*IDLE_BRIGHTNESS) for c in COLOR])
        strip.show()

    elif MODE >= 1:  # If not OFF MODE...
        samples = array.array('H', [0] * 160)
        mic.record(samples, len(samples))
        magnitude = normalized_rms(samples)
        print("Sound level:", normalized_rms(samples))
        if magnitude > SOUND_THRESHOLD:
            whip_crack(WHIP_CRACK_DURATION)
            MODE = 4
        x, y, z = sensor.acceleration
        accel_total = x * x + z * z
        # (Y axis isn't needed, due to the orientation that the Prop-Maker
        # Wing is mounted.  Also, square root isn't needed, since we're
        # comparing thresholds...use squared values instead.)
        if accel_total > HIT_THRESHOLD:  # Large acceleration = HIT
            TRIGGER_TIME = time.monotonic()  # Save initial time of hit
            #play_wav("/sounds/hit1.wav")  # Start playing 'hit' sound
            COLOR_ACTIVE = COLOR_HIT  # Set color to fade from
            MODE = 3  # HIT MODE
            print("playing HIT")
        elif MODE == 1 and accel_total > SWING_THRESHOLD:  # Mild = SWING
            # make a larson scanner animation_time
            strip.fill(DARK_COLOR)
            strip_backup = strip[0:-1]
            for p in range(-1, len(strip)):
                for i in range(p-1, p+10): # shoot a 'ray' of 3 pixels
                    if 0 <= i < len(strip):
                        strip[i] = COLOR_SWING
                strip.show()
                time.sleep(SWING_BLAST_SPEED)
                if 0 <= (p-1) < len(strip):
                    strip[p-1] = strip_backup[p-1]  # restore previous color at the tail
                strip.show()
            MODE = 2  # we'll go back to idle MODE
            print("playing SWING")
        elif MODE == 1:
            # Idle pulse
            IDLE_BRIGHTNESS += IDLE_INCREMENT  # Pulse up
            if IDLE_BRIGHTNESS > IDLE_PULSE_BRIGHTNESS_MAX or \
               IDLE_BRIGHTNESS < IDLE_PULSE_BRIGHTNESS_MIN:  # Then...
                IDLE_INCREMENT *= -1  # Pulse direction flip
            strip.fill([int(c*IDLE_BRIGHTNESS) for c in COLOR_IDLE])
            strip.show()
            time.sleep(IDLE_PULSE_SPEED)  # Idle pulse speed set above
        elif MODE > 1:  # If in SWING or HIT MODE...
            if MODE == 3:
                fade(FADE_DURATION)
#             elif MODE == 2:  # If SWING,
#                 power_on(POWER_ON_DURATION)
            MODE = 1  # Return to idle mode

Customizing Your Code

The best way to edit and upload your code is with the Mu Editor, a simple Python editor that works with Adafruit CircuitPython hardware. It's written in Python and works on Windows, MacOS, Linux and Raspberry Pi. The serial console is built right in so you get immediate feedback from your board's serial output. Instructions for installing Mu is here.

Open the code in the Mu editor (or another text editor) and look near the top. You'll see a lot of variables that you can change to customize the color palettes and sensitivity of the motion of your whip.

First, change the value of NUM_PIXELS to match the actual number of pixels in one of your NeoPixel strands. Since they're both wired to the same pin, they'll mirror each other, so we only need to count the pixels in one strand.

Download: file
NUM_PIXELS = 60  # Number of pixels in your NeoPixel strand

Colors

The default color for the whip at idle is a dark blood orange. The format here is R, G, B -- so we've made orange by mixing 40 Red, 3 Green, and 0 Blue values.  You can play with the numbers here to mix your own colors, but be sure to keep the values under around 200. Smaller numbers will make a dimmer light -- I want the idle color to be much dimmer than the animation colors, for extra contrast.

More about mixing colors in CircuitPython can be found here.

Download: file
# CUSTOMISE COLORS HERE:
COLOR = (40, 3, 0)      # Default idle is blood orange
HIT_COLOR = (0, 250, 0)  # hit color is green
LIGHT_WAVE_COLOR = (200, 50, 200) # purple
DARK_COLOR = (0, 0, 0)
CRACK_COLOR = (250, 250, 250) #white

Idle Pulse Animation Speed

Next, you can customize the speed and brightness of the idle pulse animation and the swing blast speed. 

Download: file
# CUSTOMISE IDLE PULSE SPEED HERE: 0 is fast, above 0 slows down
IDLE_PULSE_SPEED = 0  # Default is 0 seconds
SWING_BLAST_SPEED = 0.007

# CUSTOMISE BRIGHTNESS HERE: must be a number between 0 and 1
IDLE_PULSE_BRIGHTNESS_MIN = 0.1  # Default minimum idle pulse brightness
IDLE_PULSE_BRIGHTNESS_MAX = 0.5  # Default maximum idle pulse brightness

Sensitivity

This section allows you to adjust the motion and sound sensitivity of the whip. I have it set up so that the swing animation triggers fairly easily, the hit threshold is a bit harder. The sound threshold is set to 2000, to trigger only when a loud crack is heard.

These numbers will really affect the feel and control of your whip, so don't be afraid to play around with them until it all feels just right.

Download: file
# CUSTOMISE SENSITIVITY HERE: smaller numbers = more sensitive to motion
HIT_THRESHOLD = 1150
SWING_THRESHOLD = 750
SOUND_THRESHOLD = 2000

Animation Timing

Finally, you can change the duration of each animation. If you're a fast whip-cracker, you can set these to fire more quickly. Slow them down if you're more of a whip dancer with fewer cracks.

Download: file
# Set to the length in seconds for the animations
POWER_ON_DURATION  = 1.7
LIGHT_WAVE_DURATION = 1
HIT_DURATION = 2
SWING_DURATION = 0
FADE_DURATION = 1
WHIP_CRACK_DURATION = 0.5

Troubleshooting

If it doesn't seem to be working, here are a few things to try:

  1. Double check you have all the correct libraries installed. Some of the names are really similar -- make sure you've got the right ones.
  2. Try reinstalling CircuitPython again
  3. Open the REPL in the Mu editor by clicking the "serial" button. Press <ctrl>D. This will error-check your code and let you know what line number may be the problem. More about the REPL here.

More tips and tricks can be found on the Intro to CircuitPython guide and the Feather Sense guide.

This guide was first published on Jul 08, 2020. It was last updated on Jul 08, 2020.

This page (Software) was last updated on Feb 05, 2021.

Text editor powered by tinymce.