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)2,031
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
32 projects • 103 followers
Please do not ask me for free help for school or company projects. My time is not open sourced and you cannot buy it with free compliments.
Contact

Comments

Please log in or sign up to comment.