Skip to content

Commit c2f469b

Browse files
Add LittleConnectionMachine project (Pi + CircuitPython)
1 parent 94495f5 commit c2f469b

File tree

5 files changed

+539
-0
lines changed

5 files changed

+539
-0
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
CircuitPython random blinkenlights for Little Connection Machine. For
7+
Raspberry Pi Pico RP2040, but could be adapted to other CircuitPython-
8+
capable boards with two or more I2C buses. Requires adafruit_bus_device
9+
and adafruit_is31fl3731 libraries.
10+
11+
This code plays dirty pool to get fast matrix updates and is NOT good code
12+
to learn from, and might fail to work with future versions of the IS31FL3731
13+
library. But doing things The Polite Way wasn't fast enough. Explained as
14+
we go...
15+
"""
16+
17+
# pylint: disable=import-error
18+
import random
19+
import board
20+
import busio
21+
from adafruit_is31fl3731.matrix import Matrix as Display
22+
23+
BRIGHTNESS = 40 # CONFIGURABLE: LED brightness, 0 (off) to 255 (max)
24+
PERCENT = 33 # CONFIGURABLE: amount of 'on' LEDs, 0 (none) to 100 (all)
25+
26+
# This code was originally written for the Raspberry Pi Pico, but should be
27+
# portable to any CircuitPython-capable board WITH TWO OR MORE I2C BUSES.
28+
# IS31FL3731 can have one of four addresses, so to run eight of them we
29+
# need *two* I2C buses, and not all boards can provide that. Here's where
30+
# you'd define the pin numbers for a board...
31+
I2C1_SDA = board.GP4 # First I2C bus
32+
I2C1_SCL = board.GP5
33+
I2C2_SDA = board.GP26 # Second I2C bus
34+
I2C2_SCL = board.GP27
35+
36+
# pylint: disable=too-few-public-methods
37+
class FakePILImage:
38+
"""Minimal class meant to simulate a small subset of a Python PIL image,
39+
so we can pass it to the IS31FL3731 image() function later. THIS IS THE
40+
DIRTY POOL PART OF THE CODE, because CircuitPython doesn't have PIL,
41+
it's too much to handle. That image() function is normally meant for
42+
robust "desktop" Python, using the Blinka package...but it's still
43+
present (but normally goes unused) in CircuitPython. Having worked with
44+
that library source, I know exactly what object members its looking for,
45+
and can fake a minimal set here...BUT THIS MAY BREAK IF THE LIBRARY OR
46+
PIL CHANGES!"""
47+
48+
def __init__(self):
49+
self.mode = "L" # Grayscale mode in PIL
50+
self.size = (16, 9) # 16x9 pixels
51+
self.pixels = bytearray(16 * 9) # Pixel buffer
52+
53+
def tobytes(self):
54+
"""IS31 lib requests image pixels this way, more dirty pool."""
55+
return self.pixels
56+
57+
58+
# Okay, back to business...
59+
# Instantiate the two I2C buses. 400 KHz bus speed is recommended.
60+
# Default 100 KHz is a bit slow, and 1 MHz has occasional glitches.
61+
I2C = [
62+
busio.I2C(I2C1_SCL, I2C1_SDA, frequency=400000),
63+
busio.I2C(I2C2_SCL, I2C2_SDA, frequency=400000),
64+
]
65+
# Four matrices on each bus, for a total of eight...
66+
DISPLAY = [
67+
Display(I2C[0], address=0x74, frames=(0, 1)), # Upper row
68+
Display(I2C[0], address=0x75, frames=(0, 1)),
69+
Display(I2C[0], address=0x76, frames=(0, 1)),
70+
Display(I2C[0], address=0x77, frames=(0, 1)),
71+
Display(I2C[1], address=0x74, frames=(0, 1)), # Lower row
72+
Display(I2C[1], address=0x75, frames=(0, 1)),
73+
Display(I2C[1], address=0x76, frames=(0, 1)),
74+
Display(I2C[1], address=0x77, frames=(0, 1)),
75+
]
76+
77+
IMAGE = FakePILImage() # Instantiate fake PIL image object
78+
FRAME_INDEX = 0 # Double-buffering frame index
79+
80+
while True:
81+
# Draw to each display's "back" frame buffer
82+
for disp in DISPLAY:
83+
for pixel in range(0, 16 * 9): # Randomize each pixel
84+
IMAGE.pixels[pixel] = BRIGHTNESS if random.randint(1, 100) <= PERCENT else 0
85+
# Here's the function that we're NOT supposed to call in
86+
# CircuitPython, but is still present. This writes the pixel
87+
# data to the display's back buffer. Pass along our "fake" PIL
88+
# image and it accepts it.
89+
disp.image(IMAGE, frame=FRAME_INDEX)
90+
91+
# Then quickly flip all matrix display buffers to FRAME_INDEX
92+
for disp in DISPLAY:
93+
disp.frame(FRAME_INDEX, show=True)
94+
FRAME_INDEX ^= 1 # Swap buffers
95+
96+
97+
# This is actually the LESS annoying way to get fast updates. Other involved
98+
# writing IS31 registers directly and accessing intended-as-private methods
99+
# in the IS31 lib. That's a really bad look. It's pretty simple here because
100+
# this code is just drawing random dots. Producing a spatially-coherent
101+
# image would take a lot more work, because matrices are rotated, etc.
102+
# The PIL+Blinka code for Raspberry Pi easily handles such things, so
103+
# consider working with that if you need anything more sophisticated.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Audio spectrum display for Little Connection Machine. This is designed to be
7+
fun to look at, not a Serious Audio Tool(tm). Requires USB microphone & ALSA
8+
config. Prerequisite libraries include PyAudio and NumPy:
9+
sudo apt-get install libatlas-base-dev libportaudio2
10+
pip3 install numpy pyaudio
11+
See the following for ALSA config (use Stretch directions):
12+
learn.adafruit.com/usb-audio-cards-with-a-raspberry-pi/updating-alsa-config
13+
"""
14+
15+
import math
16+
import time
17+
import numpy as np
18+
import pyaudio
19+
from cm1 import CM1
20+
21+
# FFT configurables. These numbers are 'hard,' actual figures:
22+
RATE = 11025 # For audio vis, don't want or need high sample rate!
23+
FFT_SIZE = 128 # Audio samples to read per frame (for FFT input)
24+
ROWS = 32 # FFT output filtered down to this many 'buckets'
25+
# Then things start getting subjective. For example, the lower and upper
26+
# ends of the FFT output don't make a good contribution to the resulting
27+
# graph...either too noisy, or out of musical range. Clip a range between
28+
# between 0 and FFT_SIZE-1. These aren't hard science, they were determined
29+
# by playing various music and seeing what looked good:
30+
LEAST = 1 # Lowest bin of FFT output to use
31+
MOST = 111 # Highest bin of FFT output to use
32+
# And moreso. Normally, FFT results are linearly spaced by frequency,
33+
# and with music this results in a crowded low end and sparse high end.
34+
# The visualizer reformats this logarithmically so octaves are linearly
35+
# spaced...the low end is expanded, upper end compressed. But just picking
36+
# individial FFT bins will cause visual dropouts. Instead, a number of
37+
# inputs are merged into each output, and because of the logarithmic scale,
38+
# that number needs to be focused near the low end and spread out among
39+
# many samples toward the top. Again, not scientific, these were derived
40+
# empirically by throwing music at it and adjusting:
41+
FIRST_WIDTH = 2 # Width of sampling curve at low end
42+
LAST_WIDTH = 40 # Width of sampling curve at high end
43+
# Except for ROWS above, none of this is involved in the actual rendering
44+
# of the graph, just how the data is massaged. If modifying this for your
45+
# own FFT-based visualizer, you could keep this around and just change the
46+
# drawing parts of the main loop.
47+
48+
49+
class AudioSpectrum(CM1):
50+
"""Audio spectrum display for Little Connection Machine."""
51+
52+
# pylint: disable=too-many-locals
53+
def __init__(self, *args, **kwargs):
54+
super().__init__(*args, **kwargs) # CM1 base initialization
55+
56+
# Access USB mic via PyAudio
57+
audio = pyaudio.PyAudio()
58+
self.stream = audio.open(
59+
format=pyaudio.paInt16, # 16-bit int
60+
channels=1, # Mono
61+
rate=RATE,
62+
input=True,
63+
output=False,
64+
frames_per_buffer=FFT_SIZE,
65+
)
66+
67+
# Precompute a few items for the math to follow
68+
first_center_log = math.log2(LEAST + 0.5)
69+
center_log_spread = math.log2(MOST + 0.5) - first_center_log
70+
width_low_log = math.log2(FIRST_WIDTH)
71+
width_log_spread = math.log2(LAST_WIDTH) - width_low_log
72+
73+
# As mentioned earlier, each row of the graph is filtered down from
74+
# multiple FFT elements. These lists are involved in that filtering,
75+
# each has one item per row of output:
76+
self.low_bin = [] # First FFT bin that contributes to row
77+
self.bin_weight = [] # List of subsequent FFT element weightings
78+
self.bin_sum = [] # Precomputed sum of bin_weight for row
79+
self.noise = [] # Subtracted from FFT output (see note later)
80+
81+
for row in range(ROWS): # For each row...
82+
# Calc center & spread of cubic curve for bin weighting
83+
center_log = first_center_log + center_log_spread * row / (ROWS - 1)
84+
center_linear = 2**center_log
85+
width_log = width_low_log + width_log_spread * row / (ROWS - 1)
86+
width_linear = 2**width_log
87+
half_width = width_linear * 0.5
88+
lower = center_linear - half_width
89+
upper = center_linear + half_width
90+
low_bin = int(lower) # First FFT element to use
91+
hi_bin = min(FFT_SIZE - 1, int(upper)) # Last "
92+
weights = [] # FFT weights for row
93+
for bin_num in range(low_bin, hi_bin + 1):
94+
bin_center = bin_num + 0.5
95+
dist = abs(bin_center - center_linear) / half_width
96+
if dist < 1.0: # Filter out a math stragglers at either end
97+
# Bin weights have a cubic falloff curve within range:
98+
dist = 1.0 - dist # Invert dist so 1.0 is at center
99+
weight = ((3.0 - (dist * 2.0)) * dist) * dist
100+
weights.append(weight)
101+
self.bin_weight.append(weights) # Save list of weights for row
102+
self.bin_sum.append(sum(weights)) # And sum of weights
103+
self.low_bin.append(low_bin) # And first FFT bin index
104+
# FFT output always has a little "sparkle" due to ambient hum.
105+
# Subtracting a bit helps. Noise varies per element, more at low
106+
# end...this table is just a non-scientific fudge factor...
107+
self.noise.append(int(2.4 ** (4 - 4 * row / ROWS)))
108+
109+
def run(self):
110+
"""Main loop for audio visualizer."""
111+
112+
# Some tables associated with each row of the display. These are
113+
# visualizer specific, not part of the FFT processing, so they're
114+
# here instead of part of the class above.
115+
width = [0 for _ in range(ROWS)] # Current row width
116+
peak = [0 for _ in range(ROWS)] # Recent row peak
117+
dropv = [0.0 for _ in range(ROWS)] # Current peak falling speed
118+
autolevel = [32.0 for _ in range(ROWS)] # Per-row auto adjust
119+
120+
start_time = time.monotonic()
121+
frames = 0
122+
123+
while True:
124+
125+
# Read bytes from PyAudio stream, convert to int16, process
126+
# via NumPy's FFT function...
127+
data_8 = self.stream.read(FFT_SIZE * 2, exception_on_overflow=False)
128+
data_16 = np.frombuffer(data_8, np.int16)
129+
fft_out = np.fft.fft(data_16, norm="ortho")
130+
# fft_out will have FFT_SIZE * 2 elements, mirrored at center
131+
132+
# Get spectrum of first half. Instead of square root for
133+
# magnitude, use something between square and cube root.
134+
# No scientific reason, just looked good.
135+
spec_y = [
136+
(c.real * c.real + c.imag * c.imag) ** 0.4 for c in fft_out[0:FFT_SIZE]
137+
]
138+
139+
self.clear() # Clear canvas before drawing
140+
for row in range(ROWS): # Low to high freq...
141+
# Weigh & sum up all the FFT outputs affecting this row
142+
total = 0
143+
for idx, weight in enumerate(self.bin_weight[row]):
144+
total += (spec_y[self.low_bin[row] + idx]) * weight
145+
total /= self.bin_sum[row]
146+
147+
# Auto-leveling is intended to make each column 'pop'.
148+
# When a particular column isn't getting a lot of input
149+
# from the FFT, gradually boost that column's sensitivity.
150+
if total > autolevel[row]: # New level is louder
151+
# Make autolevel rise quickly if column total exceeds it
152+
autolevel[row] = autolevel[row] * 0.25 + total * 0.75
153+
else: # New level is softer
154+
# And fall slowly otherwise
155+
autolevel[row] = autolevel[row] * 0.98 + total * 0.02
156+
# Autolevel limit keeps things from getting TOO boosty.
157+
# Trial and error, no science to this number.
158+
autolevel[row] = max(autolevel[row], 20)
159+
160+
# Apply autoleveling to weighted input.
161+
# This is the prelim. row width before further filtering...
162+
total *= 18 / autolevel[row] # 18 is 1/2 display width
163+
164+
# ...then filter the column width computed above
165+
if total > width[row]:
166+
# If it's greater than this column's current width,
167+
# move column's width quickly in that direction
168+
width[row] = width[row] * 0.3 + total * 0.7
169+
else:
170+
# If less, move slowly down
171+
width[row] = width[row] * 0.5 + total * 0.5
172+
173+
# Compute "peak dots," which sort of show the recent
174+
# peak level for each column (mostly just neat to watch).
175+
if width[row] > peak[row]:
176+
# If column exceeds old peak, move peak immediately,
177+
# give it a slight upward boost.
178+
dropv[row] = (peak[row] - width[row]) * 0.07
179+
peak[row] = min(width[row], 18)
180+
else:
181+
# Otherwise, peak gradually accelerates down
182+
dropv[row] += 0.2
183+
peak[row] -= dropv[row]
184+
185+
# Draw bar for this row. It's done as a gradient,
186+
# bright toward center, dim toward edge.
187+
iwidth = int(width[row] + 0.5) # Integer width
188+
drow = ROWS - 1 - row # Display row, reverse of freq row
189+
if iwidth > 0:
190+
iwidth = min(iwidth, 18) # Clip to 18 pixels
191+
scale = self.brightness * iwidth / 18 # Center brightness
192+
for col in range(iwidth):
193+
level = int(scale * ((1.0 - col / iwidth) ** 2.6))
194+
self.draw.point([17 - col, drow], fill=level)
195+
self.draw.point([18 + col, drow], fill=level)
196+
197+
# Draw peak dot
198+
if peak[row] > 0:
199+
col = int(peak[row] + 0.5)
200+
self.draw.point([17 - col, drow], fill=self.brightness)
201+
self.draw.point([18 + col, drow], fill=self.brightness)
202+
203+
# Update matrices and show est. frames/second
204+
self.redraw()
205+
frames += 1
206+
elapsed = time.monotonic() - start_time
207+
print(frames / elapsed)
208+
209+
210+
if __name__ == "__main__":
211+
MY_APP = AudioSpectrum() # Instantiate class, calls __init__() above
212+
MY_APP.process()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Chaser lights for Little Connection Machine. Random bit patterns shift
7+
left or right in groups of four rows. Nothing functional, just looks cool.
8+
Inspired by Jurassic Park's blinky CM-5 prop. As it's a self-contained demo
9+
and not connected to any system services or optional installations, this is
10+
the simplest of the Little Connection Machine examples, making it a good
11+
starting point for your own projects...there's the least to rip out here!
12+
"""
13+
14+
import random
15+
import time
16+
from cm1 import CM1
17+
18+
DENSITY = 30 # Percentage of bits to set (0-100)
19+
FPS = 6 # Frames/second to update (roughly)
20+
21+
22+
def randbit(bitpos):
23+
"""Return a random bit value based on the global DENSITY percentage,
24+
shifted into position 'bitpos' (typically 0 or 8 for this code)."""
25+
return (random.randint(1, 100) > (100 - DENSITY)) << bitpos
26+
27+
28+
class Chaser(CM1):
29+
"""Purely decorative chaser lights for Little Connection Machine."""
30+
31+
def __init__(self, *args, **kwargs):
32+
super().__init__(*args, **kwargs) # CM1 base initialization
33+
# Initialize all bits to 0. 32 rows, 4 columns of 9-bit patterns.
34+
self.bits = [[0 for _ in range(4)] for _ in range(32)]
35+
36+
def run(self):
37+
"""Main loop for Little Connection Machine chaser lights."""
38+
39+
last_redraw_time = time.monotonic()
40+
interval = 1 / FPS # Frame-to-frame time
41+
42+
while True:
43+
self.clear() # Clear PIL self.image, part of the CM1 base class
44+
for row in range(self.image.height): # For each row...
45+
for col in range(4): # For each of 4 columns...
46+
# Rows operate in groups of 4. Some shift left, others
47+
# shift right. Empty spots are filled w/random bits.
48+
if row & 4:
49+
self.bits[row][col] = (self.bits[row][col] >> 1) + randbit(8)
50+
else:
51+
self.bits[row][col] = (self.bits[row][col] << 1) + randbit(0)
52+
# Draw new bit pattern into image...
53+
xoffset = col * 9
54+
for bit in range(9):
55+
mask = 0x100 >> bit
56+
if self.bits[row][col] & mask:
57+
# self.draw is PIL draw object in CM1 base class
58+
self.draw.point([xoffset + bit, row], fill=self.brightness)
59+
60+
# Dillydally to roughly frames/second refresh. Preferable to
61+
# time.sleep() because bit-drawing above isn't deterministic.
62+
while (time.monotonic() - last_redraw_time) < interval:
63+
pass
64+
last_redraw_time = time.monotonic()
65+
self.redraw()
66+
67+
68+
if __name__ == "__main__":
69+
MY_APP = Chaser() # Instantiate class, calls __init__() above
70+
MY_APP.process()

0 commit comments

Comments
 (0)