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