Convert a 2004 Hasbro Millennium Falcon set into a functional Bluetooth player/audio visualizer with Tiny Golang.
Things used in this project
Hardware components
Story
What is this? (Quick Summery)
This is a semi-sponsored project, although the project idea and product opinions are entirely o my own.
This is an audio visualizer with an independent Bluetooth 5.0 audio module, both powered from a 5V3A charger. The visualizer use 45 NeoPixels (WS2812 RGB LEDs) and a 0.96" OLED to display strength levels at high/middle/bass frequencies, read by a MSGEQ7 analyzer module. It can be connected to any external speakers with a 3.5mm jack.
Everything is fitted inside the inner space of a 2004 Hasbro Millennium Falcon toy set, replacing rear engine lights and add cab/interior lights. The original front and laser LEDs are also connected to the SAMD21 microcontroller.
Also, a "M3" MP3 module connects to the onboard 0.5W 8 Ohms speaker to play start-up/shutdown sound effects. A relay module turns the MP3 module off when it's not needed.
A capacitive touch sensor installed in the cockpit serves as the start-up/shutdown switch. Two potentiometers are used to adjust the levels of NeoPixel brightness level and onboard speaker volume.
The code is written in TinyGo, Golang for small places, and make use of TinyGo drivers as well as TinyDraw. The microcontroller is loaded with a Adafruit UF2 bootloader.
A bit background of how this started
In December 2020, I received a message from DFRobot, asking me if I'd like to try some of their new sensor modules. I said sure, and choose some stuff. And then they asked: will you make some projects with them?
I replied, "but I don't know what to do with them right now." And I don't want to make projects simply for the sensors' sake.
Then I had an idea: "I do have a project in mind. How about I choose something for this project?" And they agreed.
I picked a few things that are not easy to buy in Tai wan. So this has become my first (semi-)sponsored project.
Converting the Falcon
The 2004 Falcon is surprising easy to disassemble. Unscrew every screw underneath it, and it will open up easily.
MSGEQ7 makes analyzing audio simple
When I was fixing one of the Hasbro lightsaber with Arduino Nano last summer, I also found that one of our 2004 Millennium Falcon (My brothers and I own two) has broken engine lights. At that time, I have already planned to convert it into a music visualizer. But how?
One common solution is Fast Fourier Transform - translate audio signals into different frequency levels. There are FFT libraries, but how to use it is still way beyond my comprehension.
Only after DFRobot contacted me, I discovered the MSGEQ7 sensor on their website. This chip can measure audio strengths at 7 bands - 63Hz, 160Hz, 400Hz, 1kHz, 2.5kHz, 6.25kHz.
And the controlling protocol is quite simple (another source can be found here):
-Pull high RESET, wait 100 us, then pull down, wait 72 us.
-Pull high STROBE, read the analog value from OUTPUT, wait 36 us, then pull down STROBE, wait 36 us again.
Repeat this 7 times to get readings of all 7 bands.
For Arduino IDE users, you can dwnlo their MSGEQ7 driver here. You'll need to manually move it into the Arduino library directory.
So I decided to use the XY-WRBT with external speakers. An one-to-two 3.5mm output cable send the audio to both the speakers and MSGEQ7. I don't have the knowledge to build nice ones on my own, and there's not enough room anyway.
"M3" MP3 module
I have this "M3" MP3/WAV playback module from a couple of years ago made by some unknown Chinese maker. It's not sophisticated like the DFPlayer mini, but simple enough to be controlled by any languages. (Make a "01" directory in the micro SD card and name the song files as 001.xxx, 002.xxx, etc.)
It has two operating modes:
Direct mode: pull down pin A1-A9 to play song 1-9.
Binary mode: pull down A10 and use A5-A1 as binary signal (A5 is MSB). Pull all five pins up, then pull down the ones need to be 1. For example, 10110 (A5, A3, A2 pulled down) is 16 + 4 + 2 = 22nd song. So this mode supports total 31 songs.
Here I only need to play two songs, so I use the direct mode.
How I ended up choosing TinyGo
As for the microcontroller, I asked for a Firebeetle Board-M0, a beautifully-made SAMD21 board with castellated holes. Their ESP boards also looks nice...but SAMD21 boards are still uncommon in Asia.
But my main reason was (I'm sorry, DFRobot) - it looks kind of like the Adafruit Feather M0 Express. I wondered, is it possible to upload Feather M0's CircuitPython firemware onto it?
...
Yes you can.
...
(Of course, Firebeetle M0 is not a copy of Feather M0 Express -it's longer and has more GPIOs available.In fact, it's more similar to Arduino Zero than any of the Feather M0s.)
=================================================================
Firstly, I download a Feather M0 .ino UF2 bootloader and upload it via Arduino IDE.
To use Firebeetle M0 in Arduino IDE, you'll need to add the following link in preference: http://download.dfrobot.top/FireBeetle/package_DFRobot_index.json More install detail here. Be noted that the English wiki listed the wrong URL, at least at the time I wrote this.
With the UF2 bootloader installed, I can now flash CP firmwares. Actually, now three new options have opened up:
CircuitPython is really nice, with the best ecosystem of all three, but it is also the slowest. The audio visualizer has to be highly responsive, but time.sleep() in CP can only goes as far as 1 ms. (Also, only CP firmwares for Feather M0 Basic and Arduino Zero works well.)
MakeCode Maker (TypeScript) is interesting but still highly experimental, even more so than TinyGo. I've tried to use it to control the MSGEQ7 with no success.
...And I always wanted to try building something with TinyGo. Arduino C++ is still reliable, but I just don't like it much these days. I've been learning Go for some time, and TinyGo is also fast, with pretty good support to SAMD21 boards.
There's another (accidental) advantage of TinyGo: its board definition of Adafruit ItsyBitsy M0 has all pins listed, including the GPIO 6 and 8 that is not on ItsyBitsy M0. (Firebeetle M0 does not have GPIO 8, but it is connected to an onboard NeoPixel).
Why this is important? Because as Arduino Zero, TinyGo would use BOSSA to upload the code, and upload time is a bit slower. As ItsyBitsy M0 with its pre-loaded UF2 bootloader, TinyGo simply copies the.bin file onto it.
So: after changing the bootloader, now I have an fully-functional ItsyBitsy M0 which is not an ItsyBitsy M0. Of course, I have to manually press reset twice every time before upload (not able to auto-reset), but that was fine by me.
Finishing up
However, the biggest challenge I've faced in this project is not TinyGo - but how exactly to convert audio level readings to RGB LED effects.
So I was stuck for a month, without knowing how to proceed. I also installed an OLED display (it was not in the original plan) to see how MSGEQ7 works. In the end I decided simply to use the video below as my example:
Without any audio input, the readings from MSGEQ7 are always has about 1/4-1/8 of the max values. And this turned out to be a good thing: since there are always some value, the NeoPixels will never truly be dark, maintaining a sense of continuity. So I don't even need to cut out the low value. Simply convert them to brightness levels would do.
The LEDs are mapped to MSGEQ7's 7 bands as the following:
-Main NeoPixels (engine exhaust, 32 leds) - bands 3-5 (middle)
-Inner NeoPixels (under turret window, 12 leds) - bands 1-2 (bass)
-Cockpit NeoPixel (1 led) - band 6 (high)
I decided to ignore band 7 since it's rarely used by songs. I added a slow rainbow rotation effect as well, based on Adafruit's example code.
The routine is very simple:
Press the touch pad in the cockpit to "start it up". You can skip the light and sound effects by pressing the pad a bit longer.
Now it's in the audio visualizer mode. The Bluetooth receiver works independently, with or without the visualizer. A relay turns of MP3 module's power during this mode.
Press the pad again to "shut it down" (can be skipped as well).
This is the flashing command I used in the terminal:
tinygo flash -target itsybitsy-m0 -scheduler coroutines -port COMxx main
-scheduler coroutines is needed, otherwise it would cause a goroutine stack overflow error. This might be fixed in the future.
This project is compiled with TinyGo 0.16.0 and Golang 1.15.8. Future versions of TinyGo might not be compatible with this code.
P.S. A Note about Go Modules
Note: started from Go 1.16 the Go Modules is default on. From now on third-party packages (including TinyGo drivers, etc.) will be put in $GOPATH/pkg/mod.
You will have to create a module path for your own project so that Go can find these packages correctly:
<project dir>\> go mod init <module name>
This will generate a go.mod in the directory. Then
go get -u tinygo.org/x/drivers
go get -u tinygo.org/x/tinydraw
go mod tidy
That should do it.
BTW, Solo is a good Star Wars movie as long as you don't watch the part after the MAW.
Code
The Falcon Audio Visualizer
Go
// The Millennium Falcon Audio Visualizer
// with TinyGo (written under TinyGo 0.16.0/Golang 1.15.8)
// by Alan Wang
package main
import (
"image/color"
"machine"
"time"
"tinygo.org/x/drivers/ssd1306"
"tinygo.org/x/drivers/ws2812"
"tinygo.org/x/tinydraw"
)
const (
mainNeoPin = machine.D3
innerNeoPin = machine.D5
cabNeoPin = machine.D6
laserLEDsPin = machine.D7
frontLEDsPin = machine.D9
touchPadPin = machine.D10
audioStrobePin = machine.D11
audioResetPin = machine.D12
audioOutputPin = machine.A0
neoLevelPin = machine.A1
m3PowerPin = machine.A2
m3BusyPin = machine.A3
m3PlayPin1 = machine.A4
m3PlayPin2 = machine.A5
mainNeoNum = uint8(32)
innerNeoNum = uint8(12)
cabNeoNum = uint8(1)
padSkipTime = int64(750)
)
// NeoPixels struct
type NeoPixels struct {
neo ws2812.Device
num uint8
colors []color.RGBA
}
// MSGEQ7 autio analyzer struct
type MSGEQ7 struct {
strobe machine.Pin
reset machine.Pin
output machine.ADC
value [7]uint16
}
var (
display ssd1306.Device
mainNeo NeoPixels
innerNeo NeoPixels
cabNeo NeoPixels
audioAnlz MSGEQ7
touchPad = touchPadPin
neoLevel = machine.ADC{neoLevelPin}
)
func main() {
delayms(5000)
initialize() // Golang's init() dosen't work in TinyGo
for {
// waiting for starting up visualizer
for !touchPad.Get() {
cabNeo.fill(color.RGBA{R: 0, G: 32, B: 32})
cabNeo.show()
delayms(5)
}
timeStart := time.Now()
for touchPad.Get() {
}
timeEnd := time.Now()
if timeEnd.Sub(timeStart) < time.Millisecond*time.Duration(padSkipTime) {
startup()
} else {
startupSkipped() // skip startup effects if user pressed the pad long enough
}
var pos uint8
var cycle bool
for {
// read and convert audio level
audioAnlz.read()
currentNeoLevel := neoLevel.Get()
for i := 0; i < 7; i++ {
print(audioAnlz.value[i], " ")
}
println("")
// display audio level on NeoPixels
if cycle {
mainNeo.fillRange(wheel(pos+18, audioAnlz.value[3], currentNeoLevel), 0, 9)
mainNeo.fillRange(wheel(pos+9, audioAnlz.value[4], currentNeoLevel), 10, 22)
mainNeo.fillRange(wheel(pos, audioAnlz.value[2], currentNeoLevel), 22, 31)
innerNeo.fillRange(wheel(pos+85+9, audioAnlz.value[1], currentNeoLevel), 0, 5)
innerNeo.fillRange(wheel(pos+85, audioAnlz.value[0], currentNeoLevel), 6, 11)
cabNeo.fill(wheel(pos+85+18, audioAnlz.value[5], currentNeoLevel))
} else {
mainNeo.show()
innerNeo.show()
cabNeo.show()
pos++
}
// display audio level on SSD1306
display.ClearBuffer()
for i := int16(0); i < 7; i++ {
tinydraw.FilledRectangle(&display, i*18+2, 0, 16, int16(audioAnlz.value[6-i]/1024), color.RGBA{255, 255, 255, 255})
}
display.Display()
delayms(5)
cycle = !cycle
if touchPad.Get() {
break
}
}
timeStart = time.Now()
for touchPad.Get() {
}
timeEnd = time.Now()
if timeEnd.Sub(timeStart) < time.Millisecond*time.Duration(padSkipTime) {
shutdown()
} else {
shutdownSkipped() // skip shutdown effects if user pressed the pad long enough
}
delayms(1000)
}
}
// initialize pins and devices
func initialize() {
machine.InitADC()
machine.I2C0.Configure(machine.I2CConfig{Frequency: machine.TWI_FREQ_400KHZ})
// touch pad sensor
touchPad.Configure(pinMode("input"))
// NeoPixel light level potentiometer
neoLevel.Configure()
// LEDs
frontLEDsPin.Configure(pinMode("output"))
laserLEDsPin.Configure(pinMode("output"))
frontLEDsPin.High()
laserLEDsPin.High()
// M3 MP3 module
m3PowerPin.Configure(pinMode("output"))
m3PlayPin1.Configure(pinMode("output"))
m3PlayPin2.Configure(pinMode("output"))
m3PowerPin.Low()
m3PlayPin1.High()
m3PlayPin2.High()
delayms(250)
m3PowerPin.High() // turn on relay to power it up
// MSGEQ7
audioAnlz.setup(audioStrobePin, audioResetPin, audioOutputPin)
// SSD1306 OLED
display = ssd1306.NewI2C(machine.I2C0)
display.Configure(ssd1306.Config{
Address: ssd1306.Address_128_32,
Width: 128,
Height: 64,
})
display.ClearDisplay()
// NeoPixels
mainNeo.setup(mainNeoPin, mainNeoNum)
innerNeo.setup(innerNeoPin, innerNeoNum)
cabNeo.setup(cabNeoPin, cabNeoNum)
mainNeo.clear()
mainNeo.show()
innerNeo.clear()
innerNeo.show()
cabNeo.clear()
cabNeo.show()
delayms(1000)
cabNeo.fill(color.RGBA{R: 0, G: 32, B: 32})
cabNeo.show()
}
// start up visualizer with light and sound effects
func startup() {
m3PlayPin1.Low() // play startup music
cabNeo.fill(color.RGBA{R: 64, G: 64, B: 8})
cabNeo.show()
delayms(1500)
frontLEDsPin.Low()
delayms(500)
laserLEDsPin.Low()
delayms(2500)
for k := uint8(4); k <= 128; k++ {
mainNeo.fill(color.RGBA{R: k / 2, G: k / 2, B: k})
mainNeo.show()
innerNeo.fill(color.RGBA{R: k / 2, G: k / 2, B: k / 4})
innerNeo.show()
cabNeo.fill(color.RGBA{R: k, G: k, B: k / 8})
cabNeo.show()
delayms(5)
}
delayms(2500)
for k := uint8(128); k >= 65; k-- {
mainNeo.fill(color.RGBA{R: k / 3, G: k / 3, B: k})
mainNeo.show()
innerNeo.fill(color.RGBA{R: k / 2, G: k / 2, B: k / 8})
innerNeo.show()
cabNeo.fill(color.RGBA{R: k, G: k, B: k / 8})
cabNeo.show()
delayms(15)
}
delayms(9500)
for k := uint8(64); k >= 1; k-- {
mainNeo.fill(color.RGBA{R: k / 4, G: k / 4, B: k})
mainNeo.show()
innerNeo.fill(color.RGBA{R: k / 2, G: k / 2, B: k / 8})
innerNeo.show()
cabNeo.fill(color.RGBA{R: k, G: k, B: k / 8})
cabNeo.show()
delayms(25)
}
delayms(500)
mainNeo.clear()
mainNeo.show()
innerNeo.clear()
innerNeo.show()
cabNeo.clear()
cabNeo.show()
laserLEDsPin.High()
frontLEDsPin.High()
delayms(1500)
frontLEDsPin.Low()
delayms(250)
laserLEDsPin.Low()
delayms(750)
m3PlayPin1.High()
m3PowerPin.Low() // turn off MP3 module
}
// start up visualizer without effects
func startupSkipped() {
frontLEDsPin.Low()
laserLEDsPin.Low()
cabNeo.clear()
cabNeo.show()
m3PowerPin.Low()
}
// shut down visualizer with light and sound effects
func shutdown() {
m3PowerPin.High()
for k := uint8(4); k <= 128; k++ {
mainNeo.fill(color.RGBA{R: k / 2, G: k / 3, B: k})
mainNeo.show()
innerNeo.fill(color.RGBA{R: k / 2, G: k / 3, B: k / 8})
innerNeo.show()
cabNeo.fill(color.RGBA{R: k / 2, G: k / 3, B: k / 8})
cabNeo.show()
delayms(10)
}
frontLEDsPin.High()
laserLEDsPin.High()
mainNeo.clear()
mainNeo.show()
delayms(100)
frontLEDsPin.Low()
laserLEDsPin.Low()
mainNeo.fill(color.RGBA{R: 128 / 2, G: 128 / 4, B: 128 / 8})
mainNeo.show()
delayms(700)
for i := uint8(0); i < 2; i++ {
frontLEDsPin.High()
laserLEDsPin.High()
mainNeo.clear()
mainNeo.show()
delayms(50)
frontLEDsPin.Low()
laserLEDsPin.Low()
mainNeo.fill(color.RGBA{R: 128 / 2, G: 128 / 4, B: 128 / 8})
mainNeo.show()
delayms(200)
}
delayms(200)
m3PlayPin2.Low() // play shutdown sound effect
delayms(250)
display.ClearDisplay()
var cycle uint8
for i := uint8(8); i > 4; i-- {
for k := i * 16; k >= (i*16 - 64); k-- {
if cycle > 4 {
frontLEDsPin.Set(!frontLEDsPin.Get())
laserLEDsPin.Set(!laserLEDsPin.Get())
cycle = 0
} else {
cycle++
}
mainNeo.fill(color.RGBA{R: k, G: k / 8, B: 0})
mainNeo.show()
innerNeo.fill(color.RGBA{R: k, G: k / 8, B: 0})
innerNeo.show()
cabNeo.fill(color.RGBA{R: k, G: k / 8, B: 0})
cabNeo.show()
delayms(25)
}
}
delayms(250)
frontLEDsPin.High()
laserLEDsPin.High()
mainNeo.clear()
mainNeo.show()
innerNeo.clear()
innerNeo.show()
cabNeo.clear()
cabNeo.show()
delayms(1000)
m3PlayPin2.High()
}
// shut down visualizer without effects
func shutdownSkipped() {
m3PowerPin.High()
display.ClearDisplay()
frontLEDsPin.High()
laserLEDsPin.High()
mainNeo.clear()
mainNeo.show()
innerNeo.clear()
innerNeo.show()
cabNeo.clear()
cabNeo.show()
}
// === struct methods ===
// setup NeoPixels
func (ws *NeoPixels) setup(pin machine.Pin, neoNum uint8) {
pin.Configure(pinMode("output"))
ws.neo = ws2812.New(pin)
ws.num = neoNum
ws.colors = make([]color.RGBA, neoNum)
}
// fill NeoPixels with a specific color
func (ws *NeoPixels) fill(c color.RGBA) {
for i := range ws.colors {
ws.colors[i] = c
}
}
// fill certain NeoPixels with a specific color
func (ws *NeoPixels) fillRange(c color.RGBA, start, end uint8) {
for i := range ws.colors {
if uint8(i) >= start && uint8(i) <= end {
ws.colors[i] = c
}
}
}
// clear colors of NeoPixels
func (ws *NeoPixels) clear() {
ws.fill(color.RGBA{R: 0, G: 0, B: 0})
}
// write buffer into NeoPixels (for new colors to take effect)
func (ws *NeoPixels) show() {
ws.neo.WriteColors(ws.colors)
}
// setup MSGEQ7 audio analyzer
func (au *MSGEQ7) setup(strobePin, resetPin, outputPin machine.Pin) {
au.strobe = strobePin
au.reset = resetPin
au.output = machine.ADC{outputPin}
au.reset.Configure(pinMode("output"))
au.strobe.Configure(pinMode("output"))
au.output.Configure()
au.reset.Low()
au.strobe.Low()
}
// read from MSGEQ7 audio analyzer
func (au *MSGEQ7) read() {
au.reset.High()
delayus(100)
au.reset.Low()
delayus(72)
// get audio level at 63, 160, 400, 1K, 2.5K, 6.25K and 16KHz
for i := range au.value {
au.strobe.Low()
delayus(36)
au.value[i] = au.output.Get()
au.strobe.High()
delayus(36)
}
}
// === helper functions ===
// setup pin mode
func pinMode(mode string) machine.PinConfig {
if mode == "input" {
return machine.PinConfig{Mode: machine.PinInput}
} else if mode == "input_pullup" {
return machine.PinConfig{Mode: machine.PinInputPullup}
}
return machine.PinConfig{Mode: machine.PinOutput}
}
// return a rainbow color in a specific position
// this is based on Adafruit's example
func wheel(pos uint8, value uint16, level uint16) color.RGBA {
valueRatio := float32(value) / 65535
levelRatio := float32(uint16(level/1024)) / 64
var r, g, b uint8
switch {
case pos < 0 || pos > 255:
r = 0
g = 0
b = 0
case pos < 85:
r = 255 - pos*3
g = pos * 3
b = 0
case pos < 170:
pos -= 85
r = 0
g = 255 - pos*3
b = pos * 3
default:
pos -= 170
r = pos * 3
g = 0
b = 255 - pos*3
}
r = uint8(float32(r) * valueRatio * levelRatio)
g = uint8(float32(g) * valueRatio * levelRatio)
b = uint8(float32(b) * valueRatio * levelRatio)
return color.RGBA{R: r, G: g, B: b}
}
// equivalent to delay() in Arduino C++
func delayms(t time.Duration) {
time.Sleep(time.Millisecond * t)
}
// equivalent to delayMicroseconds() in Arduino C++
func delayus(t time.Duration) {
time.Sleep(time.Microsecond * t)
}