Audio Reactive Xmas Lights
December is here. Happy Holidays Everyone!
Let’s all make our Christmas lights audio reactive in the most over designed way possible.
Today we’re going to listen to and real time analyze some music, learn about my love/hate relationship with OSC protocol
, and punch out some sweet sweet 5V power to an LED String!
![]()
DEMO VIDEO!
What did I do?
* Controlling standard 4.5v Xmas lights that flicker to music.*
- Music is sent from my computer to my sound system.
- A duplicate of the music is sent to MaxMSP for analysis.
- MaxMSP checks for “Transient Events” in the music and expresses that as an integer.
- MaxMSP pumps out the int as a 10bit value via WIFI using OSC protocol.
- A Raspberry PI 400 listens for the OSC packets.
- On receiving a packet, the PI400 sets a GPIO pin to HIGH if the 10bit int is above a threshold.
- The GPIO pin is connected to the base of a transistor which opens the emitter.
- The emitter in connected to fairy lights positive pin, completing a circuit, and turning them on.
How did I do it?
You will need:
- Fairy lights!
I just found one that used 3 AA batteries and snipped off the battery case exposing the wires.
- Some kind of micro controller or SBC with at least one controllable digital GPIO pin.
- A computer for playing and analyzing the audio.
- A router (wifi optional).
- A BC639 Transistor or similar.
- A large capacitor (at least 1000uf).
- An LED (for testing).
- A 100ohm resistor (for testing).
- A small breadboard (or not, you could just freestyle solder like the champ you are).
- Some jumpers or a some female to male header pins.
There are three ingredients in this potion:
- The analyser and OSC transmitter (I used my desktop computer and MAXMSP).
- The listener (I used a PI400 with a super simple rust script).
- The circuit for driving the Leds.
Let’s look at each of them in turn.
The MaxMSP patch.
Intuitively us monkeys can feel the pulse of music. We can tell when a “musical moment” is important and when it’s filler. We want our lights to flicker to the pulse and to capture the subtle feeling of the music.
Lights that just pulse to the loudness of a signal betray our musical expectations and come off feeling technical rather than sublime
That’s not what we want.
Problem is audio signals are weak and boring and it’s hard to extract the pulse out of it. The method I’ll be using today is called a discrete transient follower. It’s very simple and robust because it narrows its focus to changes in just a small part of our spectrum. Today, I’m going to be looking to isolate the percussive elements of snare and bass from our wave form.
This patch below is just asking for the difference between each successive sample in a signal. A sample is just a number that represents the amplitude of an audio wave at a specific moment.
The sample rate matters here since faster rates will lead to finer difference, so we need some scaling to compensate. (I’m running on 48Ksamps/s).
Here is the equation this patch compiles to.
Where
k is a sample of audio
a is the scaling factor,
b is the cut off frequency of the low pass filter.
The rampsmooth~ is just a finesse that holds the output signal at level for 900 samples.
After that, the rest is just data-wrangling our signal output to a 10bit binary for OSC. Easy!
udpsend is the object responsible for sending the OSC message. Its first argument is the IP4 address you’re sending to. If you’re not sure what the IP4addr of your receiving device is, a good bash command to be familiar with is ip addr
which displays all your IP info.
OSC commands come in three parts. First, an address, second a type, and lastly a value.
e.g /oscillator_amp/float/0.32
.
Max handles the type, but it is still good practice to prepend an address and check for that address in code at the raspberry pi (or whatever micro-controller is listening for the packets).
Alternative to MaxMSP in python
Note: MaxMSP was my first love but it is proprietary software. I just used it because It’s fast, easy, and I was familiar with it.
Everything done here could be done with SciPy, JUCE or even Audacity.
All you need is a way to filter the audio to isolate the spectrum you’re interested in and a way to take the derivative of the audio vector. All of the above libraries can do that.
Here is an example in python . Tested and beautiful.
If you’re looking for quick and dirty I see no reason why you couldn’t loop through the samples of your audio and check for custom keyframes. Then just punch the keyframes over OSC. Voila!
Here is some untested dummy python code showing how I’d approach it.
import pyaudio
import wave
# Setup
chunk = 1024
wf = wave.open(filename, 'rb')
p = pyaudio.PyAudio()
# Open a .Stream object to write the WAV file to play
stream = p.open(format = p.get_format_from_width(wf.getsampwidth()),
channels = wf.getnchannels(),
rate = wf.getframerate(),
output = True)
# Read data in chunks
data = wf.readframes(chunk)
# Play the sound by writing the audio data to the stream
while data != '':
stream.write(data)
data = wf.readframes(chunk)
#DETECT ZERO CROSSING AS AUDIO PLAYS!
for samp in chunk:
if samp == 0.0 then:
print("Detected Zero Crossing. Send 1023 via OSCpacket")
else:
print("Release GPIO")
#Maybe you could also check the derivative here?
# Close and terminate the stream
stream.close()
p.terminate()
Code for OSC receiver and GPIO management. (Raspberry Pi).
Notes : The code only has two parts :
- Listening and parsing an OSC Packet.
- Setting a GPIO pin.
I know many people on this community prefer python and I am confident both 1.OSC and 2.GPIO can be re-written in python with no noticeable performance loss.
If this is your first time working with OSC I wrote a simple OSC server in python below and you’re welcome to use it for inspiration.
If this your first time working with the GPIOs on Raspberry PI the Core Electronics team have you covered with a guide below.
If you do port the below code to python I’d love to see it!
I’m also happy to try and help if you need assistance.
For those who want to plug in and play here is all the rust code you need.
I’ve left comments on the code so you can follow along.
Here we get an IP4 to listen to from the command line arguments, open up an OSC client on that socket, and the parse any incoming packets for our GPIO pins.
/*Cargo.toml*/
[package]
name = "osc_gpio_xmas23"
version = "0.1.0"
edition = "2021"
[dependencies]
rosc = "~0.10"
gpio = "0.4.1"
ctrlc = "3.4.1"
/*main.rs*/
use rosc::OscPacket;
use rosc::OscMessage;
use rosc::decoder::MTU;
use gpio::GpioOut;
use std::env;
use std::net::{SocketAddrV4, UdpSocket};
use std::str::FromStr;
use std::any::type_name;
use ctrlc;
#[allow(dead_code)]
fn type_of<T>(_: T) -> &'static str {
type_name::<T>()
}
fn get_args() -> Vec<String> {
let args: Vec<String> = env::args().collect();
// Check to see if we actually... gave arguments
if args.len() < 2 {
println!("Required: valid osc address as first commandline argument");
::std::process::exit(1)
}
return args;
}
fn extract_oscmsg(packet: OscPacket) -> OscMessage {
match packet {
OscPacket::Message(msg) => {
return msg;
}
OscPacket::Bundle(bundle) => {
panic!("OSCBundles not implemented -- received OSC Bundle: {:?}", bundle);
}
}
}
fn parse_oscarg_from_maxmsp_asf32(msg: &OscMessage) -> Result<f32, &str> {
/* Expect
1. Keyword "fromMaxMsp"
2. A signed 32 bit float.
3. Between -1024 and +1024 */
let bad_addr : &str = "Received OscMessage from unrecognised address";
let bad_args : &str = "No argument provided";
let not_f32 : &str = "Argument must be a 32bit signed floating point number";
let not_inrange : &str = "Argument expected to be between -2^10 and +2^10";
if msg.addr.trim_start_matches('/') != "fromMaxMsp" {
return Err(bad_addr);
}
if msg.args.is_empty() {return Err(bad_args)}
match msg.args[0].clone().float() {
Some(f) => match f {
x if (0.0..1024.0).contains(&x) => return Ok(f),
_ => return Err(not_inrange),
}
None => return Err(not_f32),
}
}
fn is_extremerange_f32(f : f32, r : f32) -> bool {
let is_high : bool = f > 1024.0 * r;
let is_low : bool = f < -1024.0 * r;
return is_low | is_high;
}
fn main() {
// Kill GPIOs on Keyboard Interupt
ctrlc::set_handler(move || {
let mut gpio23 = gpio::sysfs::SysFsGpioOutput::open(23).unwrap();
let mut gpio18 = gpio::sysfs::SysFsGpioOutput::open(18).unwrap();
gpio23.set_value(false).expect("could not set gpio23");
gpio18.set_value(false).expect("could not set gpio18");
println!("\r\nGoodbye");
::std::process::exit(1);
}).expect("Error setting Ctrl-C handler");
// Open GPIOs
println!("Opening GPIO Ports");
let mut gpio23 = gpio::sysfs::SysFsGpioOutput::open(23).unwrap();
let mut gpio18 = gpio::sysfs::SysFsGpioOutput::open(18).unwrap();
let mut value : bool = false;
// Open a small buffer to receive inputs
println!("Opening mutable buffer for reading incoming data");
let mut buf = [0u8; MTU];
// Attempt to parse first cmd arg as IPv4
let addr : SocketAddrV4 = match SocketAddrV4::from_str(&get_args()[1]) {
Ok(addr) => addr,
Err(_) => panic!("Invalid IPAddrV4"),
};
// Open a Udp listener
let sock : UdpSocket = UdpSocket::bind(addr).unwrap();
println!("Listening to {}", addr);
loop {
// Listen for an extract an osc message
let packet : OscPacket;
match sock.recv_from(&mut buf) {
Ok((size, addr)) => {
print!("Received Packet! size: {} from: {} ", size, addr);
let (_, pk) = rosc::decoder::decode_udp(&buf[..size]).unwrap();
packet = pk;
}
Err(e) => {
panic!("Error receiving from socket: {}", e);
}
}
let msg : OscMessage = extract_oscmsg(packet);
/* We are specifically looking for integers from max so we match on that.
Unless we are debugging, I'm happy to discard any incorrect messages */
match parse_oscarg_from_maxmsp_asf32(&msg) {
Ok(n) => {
print!("addr: {} value: {} ", msg.addr, n);
// The second argument is the fudge factor here. Try stuff till it works.
value = is_extremerange_f32(n, 0.72);
println!("GPIO: {}", value);
}
Err(e) => println!("{}",e),
}
gpio23.set_value(value).expect("could not set gpio23");
gpio18.set_value(value).expect("could not set gpio18");
}
}
#to use (bash)
cargo run - IP4tolisten:channel
Circuit Diagram. 
Note: swapping C1 for a higher value capacitor will extend the decay time of the fairy lights. I think that’s because the cap is charging almost instantaneously from the emitter of the BC639 but discharging slowly because the Light Emitting Diodes are kinda acting as a resistor.
D1 and R1 are just for debugging.

With thanks 
If you make this please show me. I’d love to see it.
Thanks to all the people on this community that take the time to answer my questions.
Have a merry xmas!
Pix