Alan Wang
Published © CC BY-NC-SA

The Falcon Audio Visualizer (a TinyGo project)

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

IntermediateShowcase (no instructions)1,246
The Falcon Audio Visualizer (a TinyGo project)

Things used in this project

Hardware components

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

Software apps and online services

TinyGo
VS Code
Microsoft VS Code

Story

Read more

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

Credits

Alan Wang

Alan Wang

30 projects • 68 followers
Former novel translator, amateur film photographer. Sometime Maker.

Comments