Jasmine Cheng

Gabriel Prado

Abstract

The goal of this project was to build a music visualizer, Headbanger, that would incorporate marbles and light to correspond with the mood and rhythm of songs. We built a two-axis pan-tilt machine, which included a face character with colored LED lights. We choreographed the machine to two contrasting songs via motorized movements and light sequences.

Objectives

We wanted to create a music visualizer to blend physical motion with creative expression. To achieve this effect, we made use of a pan/tilt mechanism and neopixels lights.

The machine could be choreographed to match diverse song types.

The combination of the pan/tilt movement and neopixels control expression of marble plate. Lights give the marble plate character and are refracted by the marbles. The pan tilt induces marble motion in sync with the music, and the marbles rhythmically roll across the plate and interact with each other.

Implementation

We built a two-axis pan tilt device that utilizes DC motors for each degree of freedom. The bottom layer is the pan motor assembly. There is a 20 tooth gear on the DC motor, connected to a 40 tooth gear attached to the ring bearing case. The top of the ring bearing case is mounted onto the panning plan. We chose to use the ring bearing case so that wires could be passed through from under the pan plate. The gearing helps us achieve slower pans.

The panning plate has tabs in it for struts for the tilt mechanism. The tilt mechanism is driven by a cam, which is powered by another DC motor with a 20 tooth (on motor) to 30 tooth (on cam) gear reduction.

We choreographed two songs to Headbanger: Tourette’s by Nirvana and To Build A Home by The Cinematic Orchestra. The former is a heavy noise rock song while the latter is a soft, melodic piece. We purposefully chose opposite song types in order to provoke diverse reactions by the audience at the show.

Outcomes

We constructed two prototypes prior to our final build.

For our first prototype, we incorporated all the planned fundamental functionality into the build. However, we had to use LEDs instead of neopixels because there was not enough space for the neopixel wires to go through the smile platform. We also included a return spring for the smile so that it would return to its original state when the cam activated.

Unfortunately, the pan mechanism of the first prototype malfunctioned often due to the ring bearing partially breaking during the build. The tilt mechanism was more reliable, but the return spring seemed to be too strong to have any realistic control of the tilt via code.

Smile platform with LEDs instead of neopixels
Use of pullback spring to return to original state

For our second prototype, we kept the fundamental mechanisms of the first prototype and mainly focused on making the motion more reliable and controllable. To do this, we used a second iteration of the ring bearing to provide better pan motion. We also removed the pullback spring and instead glued the smile platform such that its own weight would return it to the original state. We were also able to add the neopixels by giving more room in the smile to feed their wires through.

This prototype was a major improvement from the first one in terms of reliability and motion, and we were able to run code for a song on this prototype. However, there were still slight issues in its sturdiness. The glue that fixed the smile to the tilt mechanism was not strong enough, our cam was slightly oversized, and we had to improvise a counterweight for the entire assembly.

Tape used as improvided counterweight
Neopixels implemented successfullly

Taking the lessons learned from the creations of our previous prototypes, we approached the final build with further mechanical refinement. We also put a lot of focus on choreographing songs via our code. The major mechanical changes we made were adjusting the cam size and its attachment to the corresponding gear mechanism as well as using trapezoidal inserts to attach the smile platform to the tilt mechanism. These changes significantly improved the reliability and performance of Headbanger. Due to time constraints, we were only able to choreograph two songs. However, there was very little trouble mechanically in achieving this.

Here is our project video demonstrating the final build:

Future Work

To further improve upon this project, we could choreograph routines to more songs across different genres. We could add sensing such as an accelerometer that would allow for more fine tuned control of the pan and tilt, or photoelectric sensors that can allow the lights to change color when a marble triggers them.

We can also clean up the build in terms of loose wires and other aesthetic detail. In addition, the gear assembly could be more robust.

Photos

The following photos were taken by Professor Garth Zeglin at our final show:

Contribution

Jasmine wrote all of the software and designed the pan and tilt mechanisms while Gabriel designed the smile platform, conducted wiring testing, and made the project video.

Citations

Sections of software were taken from Professor Garth’s class website:

Fall 2021 Course Guide — 16-223 Creative Kinetic Systems (cmu.edu)

The crooked smile was inspired by rock band Nirvana’s smiley logo, The CAD for it was downloaded on GrabCad, and it was designed by user Javier López.

Nirvana | 3D CAD Model Library | GrabCAD

Supporting Material

Below is the source code. The files are:

code.py: Contains main event loop and input recorder

lights.py: Contains Light object that sets LED to various light sequences

motor.py: Contains motor objects that send power commands to two motors

timer.py: Contains timer object that gives elapsed time since start of the code running

motor_actions.py: Contains motor motion primitives and their respective key bindings, handles commanding motor actions, calls Motor object to set motor powers for a given primitive

code.py

import time
import math

import board
import digitalio
from motor_actions import MotorActions
import neopixel_write
import pwmio


from lights import NeoPixelLights
from motor import DRV8833
import remote
import timer

# -------------------------------------------------------------------
# Initialize

motor_driver = DRV8833()
lights = NeoPixelLights()
remote  = remote.RemoteSerial()
timer = timer.Timer()


class EventObject:
    def __init__(self, event_end):
        self.event_end = event_end

event = EventObject(0)
actions = MotorActions(motor_driver, event, timer)

log_name = "log.txt"

def log_input():
    def keypress_handler(key_in):
        actions.command_action(key_in)

    remote.add_keypress_handler(keypress_handler)
    lights.sequence = lights.flash
    while True:
        lights.poll(timer.now())
        remote.poll(0)
        actions.poll()


def countdown(countdown_time):
    # Countdown
    countup_time = 0
    while True:
        elasped_s = timer.elasped_s()

        if  elasped_s > countup_time:
            print("Starting in", countdown_time - countup_time)
            countup_time += 1

        if elasped_s > countdown_time:
            break

def do_performance(log_name):
    f = open(log_name,'r',encoding = 'utf-8')
    event_list = []
    for line in f:
        words = line.split()
        action_time = float(words[0])
        action = words[1]
        event_list.append((action_time, action))

    # ------------------------------------------------------------
    # Main Event Loop
    timer.reset()
    i = 0
    while True:
        if i % 10000 == 0:
            pass
            #print("Time elasped", elasped_s)
        i += 1

        if timer.elasped_s() > event.event_end:
            motor_driver.set_pan_motor(0)
            motor_driver.set_tilt_motor(0)
        
        if len(event_list) > 0 and timer.elasped_s() > event_list[0][0]:
            next_event = event_list.pop(0) 
            print("Event: ", next_event)
            # command_action will set the motor speeds and modify event.event_end
            actions.command_action(next_event[1])

        # Lights
        lights.color_sequence(timer.now())

        # Remote
        remote.poll(timer.elasped_s())   

countdown(3) 
log_input()
#do_performance(log_name)


motor.py

import time
import board
import digitalio
import neopixel_write
import math
import pwmio

class DRV8833():
    def __init__(self, AIN1=board.GP18, AIN2=board.GP19, BIN2=board.GP20, BIN1=board.GP21, pwm_rate=20000):
        # Create a pair of PWMOut objects for each motor channel.
        self.ain1 = pwmio.PWMOut(AIN1, duty_cycle=0, frequency=pwm_rate)
        self.ain2 = pwmio.PWMOut(AIN2, duty_cycle=0, frequency=pwm_rate)

        self.bin1 = pwmio.PWMOut(BIN1, duty_cycle=0, frequency=pwm_rate)
        self.bin2 = pwmio.PWMOut(BIN2, duty_cycle=0, frequency=pwm_rate)

    def set_pan_motor(self, rate):
        # Pan motor is Motor A
        self.write(0, rate)

    def set_tilt_motor(self, rate):
        # Tilt motor is Motor B
        self.write(1, rate)

    def write(self, channel, rate):
        """Set the speed and direction on a single motor channel.

        :param channel:  0 for motor A, 1 for motor B
        :param rate: modulation value between -1.0 and 1.0, full reverse to full forward."""

        # convert the rate into a 16-bit fixed point integer
        pwm = min(max(int(2**16 * abs(rate)), 0), 65535)

        if channel == 0:
            if rate < 0:
                self.ain1.duty_cycle = pwm
                self.ain2.duty_cycle = 0
            else:
                self.ain1.duty_cycle = 0
                self.ain2.duty_cycle = pwm
        else:
            if rate < 0:
                self.bin1.duty_cycle = pwm
                self.bin2.duty_cycle = 0
            else:
                self.bin1.duty_cycle = 0
                self.bin2.duty_cycle = pwm


motor_actions.py

import timer

# import pdb
class MotorActions:
    def __init__(self, motor_driver, event_obj, in_timer):
        self.motor_driver = motor_driver
        self.event_obj = event_obj
        self.timer = in_timer

        self.tilt_set_up = True 

        self.action_lookup = {
            'a': self.pan_right_slow,
            'd': self.pan_left_slow,
            'w': self.tilt_up,
            's': self.tilt_down,
            'h': self.tilt_up_left,
            'j': self.tilt_up_right,
            'n': self.tilt_down_left,
            'm': self.tilt_down_right,
            'l': self.swirl_movement
        }

        self.action_queue = []
        self.action_queue_start = 0
        self.action_timer = timer.Timer()
        self.current_action = None

    def poll(self):
        if self.current_action is not None:
            if self.action_timer.elasped_s() &gt; self.current_action[2]:
                # Get next action
                if len(self.action_queue) &gt; 0:
                    self.set_next_action()
                else:
                    self.motor_driver.set_pan_motor(0)
                    self.motor_driver.set_tilt_motor(0)

    def set_next_action(self):
        if len(self.action_queue) &gt; 0:
            self.current_action = self.action_queue.pop(0)
            pan, tilt, action_end = self.current_action
            self.motor_driver.set_pan_motor(pan)
            self.motor_driver.set_tilt_motor(tilt)
            self.action_timer.reset()

    def command_action(self, action_key):
        action = self.action_lookup.get(action_key)
        if action is not None:
            print(f"{self.timer.elasped_s()} {action_key}")
            action()
            self.set_next_action()

    def pan_right_slow(self):
        print('pan right')
        self.action_queue = [(-0.7, 0, 0.5)]

    def swirl_movement(self):
        if self.tilt_set_up:
            self.action_queue = [
                (0.75, -0.8, 0.75),
                (0.75, 0.8, 0.75),
                (0.75, -0.8, 0.75)
            ]
            self.tilt_set_up = False

    def pan_left_slow(self):
        print('pan left')
        self.action_queue = [(0.7, 0, 0.5)]

    def tilt_up(self):
        if not self.tilt_set_up:
            self.tilt_set_up = True
            self.action_queue = [(0, 0.75, 0.6)]
            

    def tilt_down(self):
        if self.tilt_set_up:
            self.tilt_set_up = False
            self.action_queue = [(0, -0.75, 0.5)]

    def tilt_up_left(self):
        if not self.tilt_set_up:
            self.tilt_set_up = True
            self.action_queue = [(0.7, 0.75, 0.7)]

    def tilt_down_right(self):
        if self.tilt_set_up:
            self.tilt_set_up = False
            self.action_queue = [(-0.7, -0.75, 0.5)]
    
    def tilt_up_right(self):
        if not self.tilt_set_up:
            self.tilt_set_up = True
            self.action_queue = [(-0.7, 0.75, 0.7)]

    def tilt_down_left(self):
        if self.tilt_set_up:
            self.tilt_set_up = False
            self.action_queue = [(0.7, -0.75, 0.5)]
    
   
            

    def set_action(self, pan_motor, tilt_motor, time):
        
        self.motor_driver.set_pan_motor(pan_motor)
        self.motor_driver.set_tilt_motor(tilt_motor)
        self.event_obj.event_end = self.timer.elasped_s() + time

lights.py

import digitalio
import neopixel_write
import board

class Color():
    def __init__(self, r, g, b):
        self.r = r
        self.g = g
        self.b = b

    def rgb(self):
        return self.r, self.g, self.b

RED = Color(255, 0, 0)
GREEN = Color(0, 255, 0)
BLUE = Color(0, 0, 255)
MAGENTA = Color(255, 0, 255)
YELLOW = Color(255, 255, 0)
CYAN = Color(0, 255, 255)

class NeoPixelLights():
    def __init__(self):
        self.pixels = digitalio.DigitalInOut(board.GP13)
        self.pixels.direction = digitalio.Direction.OUTPUT

        self.num_pixels = 40

        self.frame_buffer = bytearray(3*self.num_pixels)

        self.dim_factor = 50
        self.sequence = self.color_sequence

    def set_sequence(self, sequence):
        self.sequence = sequence

    def poll(self, now):
        self.sequence(now)

    def color_sequence(self, now):
        # generate a temporal color sequence with each component out of phase
        red = int((now//11000) % 256)
        grn = int((now//33000) % 256)
        blu = int((now//55000) % 256)

        # print(f"{red}, {grn}, {blu}")

        # update the entire frame buffer including an additional position-dependent term
        # to create spatial variation
        for p in range(self.num_pixels):
            self.frame_buffer[3*p]   = (grn + 12*p) % 256
            self.frame_buffer[3*p+1] = (red + 12*p) % 256
            self.frame_buffer[3*p+2] = (blu + 12*p) % 256

        for i in range(self.num_pixels * 3):
                self.frame_buffer[i] = round(self.frame_buffer[i]/self.dim_factor)

        # transfer the new frame to the NeoPixel LED strand
        neopixel_write.neopixel_write(self.pixels, self.frame_buffer)

    def eye_swirl(self, now):
        speed = 2
        
        red = int((now//(speed * 11000)) % 256)
        grn = int((now//(speed * 33000)) % 256)
        blu = int((now//(speed * 55000)) % 256)
        
        for i in range(self.num_pixels * 3):
            self.frame_buffer[i] = 0

        swirl_speed = 4
        for p in range(12):
            self.frame_buffer[3*p]   = (grn + swirl_speed*12*p) % 256
            self.frame_buffer[3*p+1] = (red + swirl_speed*12*p) % 256
            self.frame_buffer[3*p+2] = (blu + swirl_speed*12*p) % 256

            self.frame_buffer[3*(24 - p)]   = (grn + swirl_speed*12*p) % 256
            self.frame_buffer[3*(24 - p)+1] = (red + swirl_speed*12*p) % 256
            self.frame_buffer[3*(24 - p)+2] = (blu + swirl_speed*12*p) % 256

        for i in range(self.num_pixels * 3):
                self.frame_buffer[i] = round(self.frame_buffer[i]/self.dim_factor)

        # transfer the new frame to the NeoPixel LED strand
        neopixel_write.neopixel_write(self.pixels, self.frame_buffer)

    def flash(self, now):
        flash_on = 1000
        flash_off = 1000
        on = now % (flash_on + flash_off) < flash_off

        grn = 0
        red = 255
        blu = 0

        if on:
            for p in range(self.num_pixels):
                self.frame_buffer[3*p]   = grn
                self.frame_buffer[3*p+1] = red
                self.frame_buffer[3*p+2] = blu
        else:
            for i in range(self.num_pixels * 3):
                self.frame_buffer[i] = 0

        for i in range(self.num_pixels * 3):
                self.frame_buffer[i] = round(self.frame_buffer[i]/self.dim_factor)

        # transfer the new frame to the NeoPixel LED strand
        neopixel_write.neopixel_write(self.pixels, self.frame_buffer)



    def set_led_rgb(self, led_num, r, g, b):
        self.frame_buffer[3*led_num] = (g + 12*led_num) % 256
        self.frame_buffer[3*led_num+1] = (r + 12*led_num) % 256
        self.frame_buffer[3*led_num+2] = (b + 12*led_num) % 256

    def set_led_color(self, led_num, color):
        r, g, b = color.rgb
        self.set_led_rgb(led_num, r, g, b)

    def write_to_neopixel(self):
        for i in range(self.num_pixels * 3):
            self.frame_buffer[i] = round(self.frame_buffer[i]/self.dim_factor)
        neopixel_write.neopixel_write(self.pixels, self.frame_buffer)



timer.py

import time

class Timer:
    def __init__(self):
        self.start_time = self.now_s()

    def reset(self):
        self.start_time = self.now_s()

    def elasped_s(self):
        return self.now_s() - self.start_time

    def now(self):
        return time.monotonic_ns() / 1000

    def now_s(self):
        return self.now()/float(1000000)