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.


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.


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() > self.current_action[2]:
# Get next action
if len(self.action_queue) > 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) > 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)



Leave a Reply
You must be logged in to post a comment.