Skip to content

Commit c8130c3

Browse files
authored
Add files via upload
1 parent 91d76ed commit c8130c3

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed

code.py

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
# SPDX-FileCopyrightText: Adafruit Industries
2+
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
3+
#
4+
# SPDX-License-Identifier: MIT
5+
6+
# Code written by Adafruit Industries
7+
# Adafruit Circuit Playground Express Bluefruit
8+
9+
import time
10+
import math
11+
import array
12+
import board
13+
import digitalio
14+
import neopixel
15+
import analogio
16+
import audiobusio
17+
import touchio
18+
import busio
19+
import adafruit_lis3dh
20+
21+
from adafruit_ble import BLERadio
22+
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
23+
from adafruit_ble.services.nordic import UARTService
24+
25+
from adafruit_bluefruit_connect.packet import Packet
26+
from adafruit_bluefruit_connect.color_packet import ColorPacket
27+
from adafruit_bluefruit_connect.button_packet import ButtonPacket
28+
import adafruit_fancyled.adafruit_fancyled as fancy
29+
30+
# setup pixels
31+
pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=1, auto_write=True)
32+
33+
# name colors so you don't need to refer to numbers
34+
RED = (255, 0, 0)
35+
BLACK = (0, 0, 0)
36+
GREEN = (0, 255, 0)
37+
PURPLE = (100, 0, 255)
38+
BLUE = (0, 0, 255)
39+
40+
# Declare a 6-element RGB rainbow palette
41+
PALETTE_RAINBOW = [fancy.CRGB(1.0, 0.0, 0.0), # Red
42+
fancy.CRGB(0.5, 0.5, 0.0), # Yellow
43+
fancy.CRGB(0.0, 1.0, 0.0), # Green
44+
fancy.CRGB(0.0, 0.5, 0.5), # Cyan
45+
fancy.CRGB(0.0, 0.0, 1.0), # Blue
46+
fancy.CRGB(0.5, 0.0, 0.5)] # Magenta
47+
48+
NUM_LEDS = 10
49+
offset = 0 # animation position offset
50+
active_palette = None # currently running palette animation
51+
active_color = None # currently breathing solid color
52+
53+
def update_palette():
54+
"""Advance one frame of the active palette animation."""
55+
global offset
56+
if active_palette is None:
57+
return
58+
for i in range(NUM_LEDS):
59+
color = fancy.palette_lookup(active_palette, offset + i / NUM_LEDS)
60+
color = fancy.gamma_adjust(color, brightness=0.25)
61+
pixels[i] = color.pack()
62+
pixels.show()
63+
offset += 0.05
64+
65+
def update_breathing():
66+
"""Slowly breathe the active solid color between brightness 0.2 and 0.5."""
67+
if active_color is None:
68+
return
69+
# Sine wave oscillates 0-1, scale to 0.2-0.5 range
70+
brightness = 0.35 + 0.15 * math.sin(time.monotonic() * 1.5)
71+
r = int(active_color[0] * brightness)
72+
g = int(active_color[1] * brightness)
73+
b = int(active_color[2] * brightness)
74+
pixels.fill((r, g, b))
75+
76+
# --- VU Meter (audio reactive) setup ---
77+
mic = audiobusio.PDMIn(
78+
board.MICROPHONE_CLOCK, board.MICROPHONE_DATA,
79+
sample_rate=16000, bit_depth=16)
80+
samples = array.array('H', [0] * 320)
81+
82+
CURVE = 2
83+
SCALE_EXPONENT = math.pow(10, CURVE * -0.1)
84+
85+
def constrain(value, floor, ceiling):
86+
return max(floor, min(value, ceiling))
87+
88+
def log_scale(input_value, input_min, input_max, output_min, output_max):
89+
normalized_input_value = (input_value - input_min) / (input_max - input_min)
90+
return output_min + math.pow(normalized_input_value, SCALE_EXPONENT) * (output_max - output_min)
91+
last_vu_input = 0
92+
active_vu = False # VU meter mode flag
93+
94+
# VU meter colors mapped to 10 NeoPixels
95+
VU_GREEN = (0, 127, 0)
96+
VU_YELLOW = (127, 127, 0)
97+
VU_RED = (127, 0, 0)
98+
VU_OFF = (0, 0, 0)
99+
vu_colors = [VU_GREEN, VU_GREEN, VU_GREEN, VU_GREEN,
100+
VU_YELLOW, VU_YELLOW, VU_YELLOW,
101+
VU_RED, VU_RED, VU_RED]
102+
103+
def mean(values):
104+
"""Average of mic sample values."""
105+
return sum(values) / len(values)
106+
107+
def normalized_rms(values):
108+
"""Return normalized RMS of mic samples."""
109+
minbuf = int(mean(values))
110+
samples_sum = sum(
111+
float(sample - minbuf) * (sample - minbuf)
112+
for sample in values
113+
)
114+
return math.sqrt(samples_sum / len(values))
115+
116+
vu_level = 0.0 # smoothed VU level
117+
118+
def update_vu():
119+
"""Update NeoPixels based on mic input level with smooth rise and fall."""
120+
global last_vu_input, vu_level
121+
if not active_vu:
122+
return
123+
mic.record(samples, len(samples))
124+
magnitude = normalized_rms(samples)
125+
# Compute scaled logarithmic reading in the range 0 to NUM_LEDS
126+
target = log_scale(constrain(magnitude, input_floor, input_ceiling),
127+
input_floor, input_ceiling, 0, NUM_LEDS)
128+
# Smooth: rise slowly, fall even slower
129+
if target > vu_level:
130+
vu_level = vu_level + (target - vu_level) * 0.4 # rise speed
131+
else:
132+
vu_level = vu_level + (target - vu_level) * 0.15 # fall speed
133+
input_val = int(vu_level)
134+
if last_vu_input != input_val:
135+
pixels.fill(VU_OFF)
136+
for i in range(min(input_val, NUM_LEDS)):
137+
pixels[i] = vu_colors[i]
138+
pixels.show()
139+
last_vu_input = input_val
140+
141+
# Sentinel for VU meter mode in animation list
142+
VU_METER = "VU_METER"
143+
144+
# --- Light Sensor setup ---
145+
light = analogio.AnalogIn(board.LIGHT)
146+
active_light = False # Light sensor mode flag
147+
light_level = 0.0 # smoothed light level
148+
149+
# Light meter warm colors
150+
LIGHT_DIM = (52, 5, 1)
151+
LIGHT_BRIGHT = (9, 5, 4)
152+
153+
last_light_color = (0, 0, 0) # track last written color
154+
155+
def update_light():
156+
"""All 10 LEDs blend between dim and bright color based on light level."""
157+
global light_level, last_light_color
158+
if not active_light:
159+
return
160+
# 0.0 = dark room (dim color), 1.0 = bright room (bright warm color)
161+
raw = light.value
162+
target = max(0.0, min(1.0, (raw - 1000) / 1000.0))
163+
# Smooth: very gentle transitions
164+
if target > light_level:
165+
light_level = light_level + (target - light_level) * 0.02
166+
else:
167+
light_level = light_level + (target - light_level) * 0.015
168+
# Clamp to prevent drift
169+
light_level = max(0.0, min(1.0, light_level))
170+
t = light_level
171+
new_color = (int(LIGHT_DIM[0] + (LIGHT_BRIGHT[0] - LIGHT_DIM[0]) * t),
172+
int(LIGHT_DIM[1] + (LIGHT_BRIGHT[1] - LIGHT_DIM[1]) * t),
173+
int(LIGHT_DIM[2] + (LIGHT_BRIGHT[2] - LIGHT_DIM[2]) * t))
174+
# Only update pixels if the color actually changed
175+
if new_color != last_light_color:
176+
last_light_color = new_color
177+
pixels.fill(new_color)
178+
pixels.show()
179+
180+
# Sentinel for Light meter mode in animation list
181+
LIGHT_METER = "LIGHT_METER"
182+
183+
# Calibrate: record initial sample to get ambient noise floor
184+
mic.record(samples, len(samples))
185+
input_floor = normalized_rms(samples) + 2
186+
input_ceiling = input_floor + .5
187+
188+
# setup bluetooth
189+
ble = BLERadio()
190+
uart_service = UARTService()
191+
advertisement = ProvideServicesAdvertisement(uart_service)
192+
193+
# setup physical buttons
194+
button_a = digitalio.DigitalInOut(board.D4)
195+
button_a.direction = digitalio.Direction.INPUT
196+
button_a.pull = digitalio.Pull.DOWN
197+
198+
button_b = digitalio.DigitalInOut(board.D5)
199+
button_b.direction = digitalio.Direction.INPUT
200+
button_b.pull = digitalio.Pull.DOWN
201+
202+
# Capacitive touch pads for brightness
203+
touch_bright = touchio.TouchIn(board.A1) # D6 - increase brightness
204+
touch_dim = touchio.TouchIn(board.A2) # D9 - decrease brightness
205+
prev_touch_bright = False
206+
prev_touch_dim = False
207+
208+
# Setup accelerometer for tap detection
209+
accelo_i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
210+
accelo = adafruit_lis3dh.LIS3DH_I2C(accelo_i2c, address=0x19)
211+
accelo.set_tap(1, 100) # single tap, threshold 100 (medium tap)
212+
213+
# Lists for cycling
214+
COLOR_LIST = [PURPLE, GREEN, RED, BLUE, LIGHT_METER]
215+
PALETTE_LIST = [PALETTE_RAINBOW, VU_METER]
216+
ALL_MODES = [PURPLE, GREEN, RED, BLUE, LIGHT_METER,
217+
PALETTE_RAINBOW, VU_METER]
218+
color_index = 0
219+
palette_index = 0
220+
all_modes_index = ALL_MODES.index(LIGHT_METER) + 1 # next mode after light meter
221+
BRIGHTNESS_STEP = 0.1
222+
prev_button_a = False
223+
prev_button_b = False
224+
225+
def apply_mode(selection):
226+
"""Apply a mode from any list, clearing all other modes."""
227+
global active_palette, active_color, active_vu, active_light
228+
global vu_level, last_vu_input, light_level, last_light_color
229+
global input_floor, input_ceiling
230+
active_palette = None
231+
active_color = None
232+
active_vu = False
233+
active_light = False
234+
if selection == VU_METER:
235+
vu_level = 0.0
236+
last_vu_input = 0
237+
pixels.fill(VU_OFF)
238+
pixels.show()
239+
# Let tap vibration settle before listening
240+
time.sleep(0.15)
241+
# Flush stale mic data, then recalibrate noise floor
242+
for _ in range(3):
243+
mic.record(samples, len(samples))
244+
mic.record(samples, len(samples))
245+
input_floor = normalized_rms(samples) + 2
246+
input_ceiling = input_floor + 0.5
247+
active_vu = True
248+
elif selection == LIGHT_METER:
249+
light_level = 0.0
250+
last_light_color = (0, 0, 0)
251+
active_light = True
252+
elif isinstance(selection, list):
253+
active_palette = selection
254+
else:
255+
active_color = selection
256+
257+
while True:
258+
# set CPXb up so that it can be discovered by the app
259+
ble.start_advertising(advertisement)
260+
# Start with light meter mode
261+
apply_mode(LIGHT_METER)
262+
_ = accelo.tapped # clear any startup tap
263+
time.sleep(0.5) # brief delay to ignore boot vibration
264+
while not ble.connected:
265+
# Check physical buttons while waiting
266+
if button_a.value and not prev_button_a:
267+
apply_mode(COLOR_LIST[color_index])
268+
color_index = (color_index + 1) % len(COLOR_LIST)
269+
if button_b.value and not prev_button_b:
270+
apply_mode(PALETTE_LIST[palette_index])
271+
palette_index = (palette_index + 1) % len(PALETTE_LIST)
272+
prev_button_a = button_a.value
273+
prev_button_b = button_b.value
274+
# Check capacitive touch for brightness
275+
if touch_bright.value and not prev_touch_bright:
276+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
277+
if touch_dim.value and not prev_touch_dim:
278+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
279+
prev_touch_bright = touch_bright.value
280+
prev_touch_dim = touch_dim.value
281+
# Check accelerometer tap to cycle modes
282+
if accelo.tapped:
283+
apply_mode(ALL_MODES[all_modes_index])
284+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
285+
update_palette()
286+
update_breathing()
287+
update_vu()
288+
update_light()
289+
time.sleep(0.02)
290+
291+
# Now we're connected
292+
293+
while ble.connected:
294+
# Check physical buttons
295+
if button_a.value and not prev_button_a:
296+
apply_mode(COLOR_LIST[color_index])
297+
color_index = (color_index + 1) % len(COLOR_LIST)
298+
if button_b.value and not prev_button_b:
299+
apply_mode(PALETTE_LIST[palette_index])
300+
palette_index = (palette_index + 1) % len(PALETTE_LIST)
301+
prev_button_a = button_a.value
302+
prev_button_b = button_b.value
303+
# Check capacitive touch for brightness
304+
if touch_bright.value and not prev_touch_bright:
305+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
306+
if touch_dim.value and not prev_touch_dim:
307+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
308+
prev_touch_bright = touch_bright.value
309+
prev_touch_dim = touch_dim.value
310+
# Check accelerometer tap to cycle modes
311+
if accelo.tapped:
312+
apply_mode(ALL_MODES[all_modes_index])
313+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
314+
315+
# Keep animating the active mode
316+
update_palette()
317+
update_breathing()
318+
update_vu()
319+
update_light()
320+
321+
if uart_service.in_waiting:
322+
try:
323+
packet = Packet.from_stream(uart_service)
324+
except ValueError:
325+
continue # or pass.
326+
327+
if isinstance(packet, ColorPacket): # check if a color was sent from color picker
328+
active_palette = None
329+
active_color = None
330+
active_vu = False
331+
active_light = False
332+
pixels.fill(packet.color)
333+
if isinstance(packet, ButtonPacket): # check if a button was pressed from control pad
334+
if packet.pressed:
335+
if packet.button == ButtonPacket.BUTTON_1: # Rainbow palette
336+
apply_mode(PALETTE_RAINBOW)
337+
if packet.button == ButtonPacket.BUTTON_2: # VU Meter
338+
apply_mode(VU_METER)
339+
if packet.button == ButtonPacket.BUTTON_3: # Light Meter
340+
apply_mode(LIGHT_METER)
341+
if packet.button == ButtonPacket.BUTTON_4: # Light Meter
342+
apply_mode(LIGHT_METER)
343+
if packet.button == ButtonPacket.UP: # Brighten
344+
pixels.brightness = min(1.0, pixels.brightness + BRIGHTNESS_STEP)
345+
if packet.button == ButtonPacket.DOWN: # Dim
346+
pixels.brightness = max(0.05, pixels.brightness - BRIGHTNESS_STEP)
347+
if packet.button == ButtonPacket.LEFT: # Cycle modes backward
348+
all_modes_index = (all_modes_index - 1) % len(ALL_MODES)
349+
apply_mode(ALL_MODES[all_modes_index])
350+
if packet.button == ButtonPacket.RIGHT: # Cycle modes forward
351+
apply_mode(ALL_MODES[all_modes_index])
352+
all_modes_index = (all_modes_index + 1) % len(ALL_MODES)
353+
354+
time.sleep(0.02) # small delay for smooth animation

0 commit comments

Comments
 (0)