|
| 1 | +""" |
| 2 | +AUDIO SPECTRUM LIGHT SHOW for Adafruit EyeLights (LED Glasses + Driver). |
| 3 | +Uses onboard microphone and a lot of math to react to music. |
| 4 | +""" |
| 5 | + |
| 6 | +from array import array |
| 7 | +from math import log |
| 8 | +from time import monotonic |
| 9 | +from supervisor import reload |
| 10 | +import board |
| 11 | +from audiobusio import PDMIn |
| 12 | +from busio import I2C |
| 13 | +import adafruit_is31fl3741 |
| 14 | +from adafruit_is31fl3741.adafruit_ledglasses import LED_Glasses |
| 15 | +from rainbowio import colorwheel |
| 16 | +from ulab import numpy as np |
| 17 | +from ulab.scipy.signal import spectrogram |
| 18 | + |
| 19 | + |
| 20 | +# FFT/SPECTRUM CONFIG ---- |
| 21 | + |
| 22 | +fft_size = 256 # Sample size for Fourier transform, MUST be power of two |
| 23 | +spectrum_size = fft_size // 2 # Output spectrum is 1/2 of FFT result |
| 24 | +# Bottom of spectrum tends to be noisy, while top often exceeds musical |
| 25 | +# range and is just harmonics, so clip both ends off: |
| 26 | +low_bin = 10 # Lowest bin of spectrum that contributes to graph |
| 27 | +high_bin = 75 # Highest bin " |
| 28 | + |
| 29 | + |
| 30 | +# HARDWARE SETUP --------- |
| 31 | + |
| 32 | +# Manually declare I2C (not board.I2C() directly) to access 1 MHz speed... |
| 33 | +i2c = I2C(board.SCL, board.SDA, frequency=1000000) |
| 34 | + |
| 35 | +# Initialize the IS31 LED driver, buffered for smoother animation |
| 36 | +glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER) |
| 37 | +glasses.show() # Clear any residue on startup |
| 38 | +glasses.global_current = 5 # Not too bright please |
| 39 | + |
| 40 | +# Initialize mic and allocate recording buffer (default rate is 16 MHz) |
| 41 | +mic = PDMIn(board.MICROPHONE_CLOCK, board.MICROPHONE_DATA, bit_depth=16) |
| 42 | +rec_buf = array("H", [0] * fft_size) # 16-bit audio samples |
| 43 | + |
| 44 | + |
| 45 | +# FFT/SPECTRUM SETUP ----- |
| 46 | + |
| 47 | +# To keep the display lively, tables are precomputed where each column of |
| 48 | +# the matrix (of which there are few) is the sum value and weighting of |
| 49 | +# several bins from the FFT spectrum output (of which there are many). |
| 50 | +# The tables also help visually linearize the output so octaves are evenly |
| 51 | +# spaced, as on a piano keyboard, whereas the source spectrum data is |
| 52 | +# spaced by frequency in Hz. |
| 53 | +column_table = [] |
| 54 | + |
| 55 | +spectrum_bits = log(spectrum_size, 2) # e.g. 7 for 128-bin spectrum |
| 56 | +# Scale low_bin and high_bin to 0.0 to 1.0 equivalent range in spectrum |
| 57 | +low_frac = log(low_bin, 2) / spectrum_bits |
| 58 | +frac_range = log(high_bin, 2) / spectrum_bits - low_frac |
| 59 | + |
| 60 | +for column in range(glasses.width): |
| 61 | + # Determine the lower and upper frequency range for this column, as |
| 62 | + # fractions within the scaled 0.0 to 1.0 spectrum range. 0.95 below |
| 63 | + # creates slight frequency overlap between columns, looks nicer. |
| 64 | + lower = low_frac + frac_range * (column / glasses.width * 0.95) |
| 65 | + upper = low_frac + frac_range * ((column + 1) / glasses.width) |
| 66 | + mid = (lower + upper) * 0.5 # Center of lower-to-upper range |
| 67 | + half_width = (upper - lower) * 0.5 # 1/2 of lower-to-upper range |
| 68 | + # Map fractions back to spectrum bin indices that contribute to column |
| 69 | + first_bin = int(2 ** (spectrum_bits * lower) + 1e-4) |
| 70 | + last_bin = int(2 ** (spectrum_bits * upper) + 1e-4) |
| 71 | + bin_weights = [] # Each spectrum bin's weighting will be added here |
| 72 | + for bin_index in range(first_bin, last_bin + 1): |
| 73 | + # Find distance from column's overall center to individual bin's |
| 74 | + # center, expressed as 0.0 (bin at center) to 1.0 (bin at limit of |
| 75 | + # lower-to-upper range). |
| 76 | + bin_center = log(bin_index + 0.5, 2) / spectrum_bits |
| 77 | + dist = abs(bin_center - mid) / half_width |
| 78 | + if dist < 1.0: # Filter out a few math stragglers at either end |
| 79 | + # Bin weights have a cubic falloff curve within range: |
| 80 | + dist = 1.0 - dist # Invert dist so 1.0 is at center |
| 81 | + bin_weights.append(((3.0 - (dist * 2.0)) * dist) * dist) |
| 82 | + # Scale bin weights so total is 1.0 for each column, but then mute |
| 83 | + # lower columns slightly and boost higher columns. It graphs better. |
| 84 | + total = sum(bin_weights) |
| 85 | + bin_weights = [ |
| 86 | + (weight / total) * (0.8 + idx / glasses.width * 1.4) |
| 87 | + for idx, weight in enumerate(bin_weights) |
| 88 | + ] |
| 89 | + # List w/five elements is stored for each column: |
| 90 | + # 0: Index of the first spectrum bin that impacts this column. |
| 91 | + # 1: A list of bin weights, starting from index above, length varies. |
| 92 | + # 2: Color for drawing this column on the LED matrix. The 225 is on |
| 93 | + # purpose, providing hues from red to purple, leaving out magenta. |
| 94 | + # 3: Current height of the 'falling dot', updated each frame |
| 95 | + # 4: Current velocity of the 'falling dot', updated each frame |
| 96 | + column_table.append( |
| 97 | + [ |
| 98 | + first_bin - low_bin, |
| 99 | + bin_weights, |
| 100 | + colorwheel(225 * column / glasses.width), |
| 101 | + glasses.height, |
| 102 | + 0.0, |
| 103 | + ] |
| 104 | + ) |
| 105 | +# print(column_table) |
| 106 | + |
| 107 | + |
| 108 | +# MAIN LOOP ------------- |
| 109 | + |
| 110 | +dynamic_level = 10 # For responding to changing volume levels |
| 111 | +frames, start_time = 0, monotonic() # For frames-per-second calc |
| 112 | + |
| 113 | +while True: |
| 114 | + # The try/except here is because VERY INFREQUENTLY the I2C bus will |
| 115 | + # encounter an error when accessing the LED driver, whether from bumping |
| 116 | + # around the wires or sometimes an I2C device just gets wedged. To more |
| 117 | + # robustly handle the latter, the code will restart if that happens. |
| 118 | + try: |
| 119 | + mic.record(rec_buf, fft_size) # Record batch of 16-bit samples |
| 120 | + samples = np.array(rec_buf) # Convert to ndarray |
| 121 | + # Compute spectrogram and trim results. Only the left half is |
| 122 | + # normally needed (right half is mirrored), but we trim further as |
| 123 | + # only the low_bin to high_bin elements are interesting to graph. |
| 124 | + spectrum = spectrogram(samples)[low_bin : high_bin + 1] |
| 125 | + # Linearize spectrum output. spectrogram() is always nonnegative, |
| 126 | + # but add a tiny value to change any zeros to nonzero numbers |
| 127 | + # (avoids rare 'inf' error) |
| 128 | + spectrum = np.log(spectrum + 1e-7) |
| 129 | + # Determine minimum & maximum across all spectrum bins, with limits |
| 130 | + lower = max(np.min(spectrum), 4) |
| 131 | + upper = min(max(np.max(spectrum), lower + 6), 20) |
| 132 | + |
| 133 | + # Adjust dynamic level to current spectrum output, keeps the graph |
| 134 | + # 'lively' as ambient volume changes. Sparkle but don't saturate. |
| 135 | + if upper > dynamic_level: |
| 136 | + # Got louder. Move level up quickly but allow initial "bump." |
| 137 | + dynamic_level = upper * 0.7 + dynamic_level * 0.3 |
| 138 | + else: |
| 139 | + # Got quieter. Ease level down, else too many bumps. |
| 140 | + dynamic_level = dynamic_level * 0.5 + lower * 0.5 |
| 141 | + |
| 142 | + # Apply vertical scale to spectrum data. Results may exceed |
| 143 | + # matrix height...that's OK, adds impact! |
| 144 | + data = (spectrum - lower) * (7 / (dynamic_level - lower)) |
| 145 | + |
| 146 | + for column, element in enumerate(column_table): |
| 147 | + # Start BELOW matrix and accumulate bin weights UP, saves math |
| 148 | + first_bin = element[0] |
| 149 | + column_top = glasses.height + 1 |
| 150 | + for bin_offset, weight in enumerate(element[1]): |
| 151 | + column_top -= data[first_bin + bin_offset] * weight |
| 152 | + |
| 153 | + if column_top < element[3]: # Above current falling dot? |
| 154 | + element[3] = column_top - 0.5 # Move dot up |
| 155 | + element[4] = 0 # and clear out velocity |
| 156 | + else: |
| 157 | + element[3] += element[4] # Move dot down |
| 158 | + element[4] += 0.2 # and accelerate |
| 159 | + |
| 160 | + column_top = int(column_top) # Quantize to pixel space |
| 161 | + for row in range(column_top): # Erase area above column |
| 162 | + glasses.pixel(column, row, 0) |
| 163 | + for row in range(column_top, 5): # Draw column |
| 164 | + glasses.pixel(column, row, element[2]) |
| 165 | + glasses.pixel(column, int(element[3]), 0xE08080) # Draw peak dot |
| 166 | + |
| 167 | + glasses.show() # Buffered mode MUST use show() to refresh matrix |
| 168 | + |
| 169 | + frames += 1 |
| 170 | + # print(frames / (monotonic() - start_time), "FPS") |
| 171 | + |
| 172 | + except OSError: # See "try" notes above regarding rare I2C errors. |
| 173 | + print("Restarting") |
| 174 | + reload() |
0 commit comments