The Falcon Audio Visualizer (a TinyGo project)

0 1548 Medium

Convert a 2004 Hasbro Millennium Falcon set into a functional Bluetooth player/audio visualizer with Tiny Golang.

projectImage

Things used in this project

 

Hardware components

HARDWARE LIST
1 DFRobot Firebeetle Board-M0
1 DFRobot Audio Analyzer Module
1 XY-WRBT Bluetooth 5.0 Audio Receiver Board
4 LED Stick, NeoPixel Stick
1 SparkFun RGB LED Breakout - WS2812B
1 Adafruit NeoPixel Ring: WS2812 5050 RGB LED Adafruit NeoPixel Ring: WS2812 5050 RGB LED
2 DFRobot Gravity:Analog Rotation Potentiometer Sensor V1 For Arduino
1 M3 MP3 Playback Module
1 ElectroPeak 0.96" OLED 64x128 Display Module
1 SparkFun Qwiic Single Relay
1 Audio Adapter, 3.5 mm Stereo Plug to 2x Sockets
1 Phone Audio Connector, 3.5mm
40 Jumper wires (generic)
1 Breadboard (generic)

Software apps and online services

 

TinyGo

 

Microsoft VS Code

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.

projectImage

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 Taiwan. 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.

projectImage
projectImage
projectImage
projectImage
projectImage
projectImage

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.

 

projectImage

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.

projectImage

For Arduino IDE users, you can dwnlo their MSGEQ7 driver here. You'll need to manually move it into the Arduino library directory.

Bluetooth receiver

 

The audio source comes from a Bluetooth receiver module. Actually I asked for two of them (both are not made by DFRobot): MH-M38 and XY-WRBT. The MH-M38 (Bluetooth 4.2) can directly power two 5W speakers, but the signal is too messed up to be read by MSGEQ7.

projectImage

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.

projectImage

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.

projectImage

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.

projectImage

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.

projectImage
projectImage
projectImage

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:

CODE
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:

CODE
<project dir>\> go mod init <module name>

This will generate a go.mod in the directory. Then

CODE
go get -u tinygo.org/x/drivers
go get -u tinygo.org/x/tinydraw
go mod tidy

That should do it.

projectImage

BTW, Solo is a good Star Wars movie as long as you don't watch the part after the MAW.

projectImage

Code

 

The Falcon Audio Visualizer

Go

CODE
// 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)
}

The article was first published in hackster, February 17 2022

cr: https://www.hackster.io/alankrantas/the-falcon-audio-visualizer-a-tinygo-project-260360

author: Alan Wang

License
All Rights
Reserved
licensBg
0