Add LittleConnectionMachine project (Pi + CircuitPython)

This commit is contained in:
Phillip Burgess 2022-04-15 10:34:58 -07:00
parent 94495f5af7
commit c2f469b3ef
5 changed files with 539 additions and 0 deletions

View file

@ -0,0 +1,103 @@
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
CircuitPython random blinkenlights for Little Connection Machine. For
Raspberry Pi Pico RP2040, but could be adapted to other CircuitPython-
capable boards with two or more I2C buses. Requires adafruit_bus_device
and adafruit_is31fl3731 libraries.
This code plays dirty pool to get fast matrix updates and is NOT good code
to learn from, and might fail to work with future versions of the IS31FL3731
library. But doing things The Polite Way wasn't fast enough. Explained as
we go...
"""
# pylint: disable=import-error
import random
import board
import busio
from adafruit_is31fl3731.matrix import Matrix as Display
BRIGHTNESS = 40 # CONFIGURABLE: LED brightness, 0 (off) to 255 (max)
PERCENT = 33 # CONFIGURABLE: amount of 'on' LEDs, 0 (none) to 100 (all)
# This code was originally written for the Raspberry Pi Pico, but should be
# portable to any CircuitPython-capable board WITH TWO OR MORE I2C BUSES.
# IS31FL3731 can have one of four addresses, so to run eight of them we
# need *two* I2C buses, and not all boards can provide that. Here's where
# you'd define the pin numbers for a board...
I2C1_SDA = board.GP4 # First I2C bus
I2C1_SCL = board.GP5
I2C2_SDA = board.GP26 # Second I2C bus
I2C2_SCL = board.GP27
# pylint: disable=too-few-public-methods
class FakePILImage:
"""Minimal class meant to simulate a small subset of a Python PIL image,
so we can pass it to the IS31FL3731 image() function later. THIS IS THE
DIRTY POOL PART OF THE CODE, because CircuitPython doesn't have PIL,
it's too much to handle. That image() function is normally meant for
robust "desktop" Python, using the Blinka package...but it's still
present (but normally goes unused) in CircuitPython. Having worked with
that library source, I know exactly what object members its looking for,
and can fake a minimal set here...BUT THIS MAY BREAK IF THE LIBRARY OR
PIL CHANGES!"""
def __init__(self):
self.mode = "L" # Grayscale mode in PIL
self.size = (16, 9) # 16x9 pixels
self.pixels = bytearray(16 * 9) # Pixel buffer
def tobytes(self):
"""IS31 lib requests image pixels this way, more dirty pool."""
return self.pixels
# Okay, back to business...
# Instantiate the two I2C buses. 400 KHz bus speed is recommended.
# Default 100 KHz is a bit slow, and 1 MHz has occasional glitches.
I2C = [
busio.I2C(I2C1_SCL, I2C1_SDA, frequency=400000),
busio.I2C(I2C2_SCL, I2C2_SDA, frequency=400000),
]
# Four matrices on each bus, for a total of eight...
DISPLAY = [
Display(I2C[0], address=0x74, frames=(0, 1)), # Upper row
Display(I2C[0], address=0x75, frames=(0, 1)),
Display(I2C[0], address=0x76, frames=(0, 1)),
Display(I2C[0], address=0x77, frames=(0, 1)),
Display(I2C[1], address=0x74, frames=(0, 1)), # Lower row
Display(I2C[1], address=0x75, frames=(0, 1)),
Display(I2C[1], address=0x76, frames=(0, 1)),
Display(I2C[1], address=0x77, frames=(0, 1)),
]
IMAGE = FakePILImage() # Instantiate fake PIL image object
FRAME_INDEX = 0 # Double-buffering frame index
while True:
# Draw to each display's "back" frame buffer
for disp in DISPLAY:
for pixel in range(0, 16 * 9): # Randomize each pixel
IMAGE.pixels[pixel] = BRIGHTNESS if random.randint(1, 100) <= PERCENT else 0
# Here's the function that we're NOT supposed to call in
# CircuitPython, but is still present. This writes the pixel
# data to the display's back buffer. Pass along our "fake" PIL
# image and it accepts it.
disp.image(IMAGE, frame=FRAME_INDEX)
# Then quickly flip all matrix display buffers to FRAME_INDEX
for disp in DISPLAY:
disp.frame(FRAME_INDEX, show=True)
FRAME_INDEX ^= 1 # Swap buffers
# This is actually the LESS annoying way to get fast updates. Other involved
# writing IS31 registers directly and accessing intended-as-private methods
# in the IS31 lib. That's a really bad look. It's pretty simple here because
# this code is just drawing random dots. Producing a spatially-coherent
# image would take a lot more work, because matrices are rotated, etc.
# The PIL+Blinka code for Raspberry Pi easily handles such things, so
# consider working with that if you need anything more sophisticated.

View file

@ -0,0 +1,212 @@
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Audio spectrum display for Little Connection Machine. This is designed to be
fun to look at, not a Serious Audio Tool(tm). Requires USB microphone & ALSA
config. Prerequisite libraries include PyAudio and NumPy:
sudo apt-get install libatlas-base-dev libportaudio2
pip3 install numpy pyaudio
See the following for ALSA config (use Stretch directions):
learn.adafruit.com/usb-audio-cards-with-a-raspberry-pi/updating-alsa-config
"""
import math
import time
import numpy as np
import pyaudio
from cm1 import CM1
# FFT configurables. These numbers are 'hard,' actual figures:
RATE = 11025 # For audio vis, don't want or need high sample rate!
FFT_SIZE = 128 # Audio samples to read per frame (for FFT input)
ROWS = 32 # FFT output filtered down to this many 'buckets'
# Then things start getting subjective. For example, the lower and upper
# ends of the FFT output don't make a good contribution to the resulting
# graph...either too noisy, or out of musical range. Clip a range between
# between 0 and FFT_SIZE-1. These aren't hard science, they were determined
# by playing various music and seeing what looked good:
LEAST = 1 # Lowest bin of FFT output to use
MOST = 111 # Highest bin of FFT output to use
# And moreso. Normally, FFT results are linearly spaced by frequency,
# and with music this results in a crowded low end and sparse high end.
# The visualizer reformats this logarithmically so octaves are linearly
# spaced...the low end is expanded, upper end compressed. But just picking
# individial FFT bins will cause visual dropouts. Instead, a number of
# inputs are merged into each output, and because of the logarithmic scale,
# that number needs to be focused near the low end and spread out among
# many samples toward the top. Again, not scientific, these were derived
# empirically by throwing music at it and adjusting:
FIRST_WIDTH = 2 # Width of sampling curve at low end
LAST_WIDTH = 40 # Width of sampling curve at high end
# Except for ROWS above, none of this is involved in the actual rendering
# of the graph, just how the data is massaged. If modifying this for your
# own FFT-based visualizer, you could keep this around and just change the
# drawing parts of the main loop.
class AudioSpectrum(CM1):
"""Audio spectrum display for Little Connection Machine."""
# pylint: disable=too-many-locals
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # CM1 base initialization
# Access USB mic via PyAudio
audio = pyaudio.PyAudio()
self.stream = audio.open(
format=pyaudio.paInt16, # 16-bit int
channels=1, # Mono
rate=RATE,
input=True,
output=False,
frames_per_buffer=FFT_SIZE,
)
# Precompute a few items for the math to follow
first_center_log = math.log2(LEAST + 0.5)
center_log_spread = math.log2(MOST + 0.5) - first_center_log
width_low_log = math.log2(FIRST_WIDTH)
width_log_spread = math.log2(LAST_WIDTH) - width_low_log
# As mentioned earlier, each row of the graph is filtered down from
# multiple FFT elements. These lists are involved in that filtering,
# each has one item per row of output:
self.low_bin = [] # First FFT bin that contributes to row
self.bin_weight = [] # List of subsequent FFT element weightings
self.bin_sum = [] # Precomputed sum of bin_weight for row
self.noise = [] # Subtracted from FFT output (see note later)
for row in range(ROWS): # For each row...
# Calc center & spread of cubic curve for bin weighting
center_log = first_center_log + center_log_spread * row / (ROWS - 1)
center_linear = 2**center_log
width_log = width_low_log + width_log_spread * row / (ROWS - 1)
width_linear = 2**width_log
half_width = width_linear * 0.5
lower = center_linear - half_width
upper = center_linear + half_width
low_bin = int(lower) # First FFT element to use
hi_bin = min(FFT_SIZE - 1, int(upper)) # Last "
weights = [] # FFT weights for row
for bin_num in range(low_bin, hi_bin + 1):
bin_center = bin_num + 0.5
dist = abs(bin_center - center_linear) / half_width
if dist < 1.0: # Filter out a math stragglers at either end
# Bin weights have a cubic falloff curve within range:
dist = 1.0 - dist # Invert dist so 1.0 is at center
weight = ((3.0 - (dist * 2.0)) * dist) * dist
weights.append(weight)
self.bin_weight.append(weights) # Save list of weights for row
self.bin_sum.append(sum(weights)) # And sum of weights
self.low_bin.append(low_bin) # And first FFT bin index
# FFT output always has a little "sparkle" due to ambient hum.
# Subtracting a bit helps. Noise varies per element, more at low
# end...this table is just a non-scientific fudge factor...
self.noise.append(int(2.4 ** (4 - 4 * row / ROWS)))
def run(self):
"""Main loop for audio visualizer."""
# Some tables associated with each row of the display. These are
# visualizer specific, not part of the FFT processing, so they're
# here instead of part of the class above.
width = [0 for _ in range(ROWS)] # Current row width
peak = [0 for _ in range(ROWS)] # Recent row peak
dropv = [0.0 for _ in range(ROWS)] # Current peak falling speed
autolevel = [32.0 for _ in range(ROWS)] # Per-row auto adjust
start_time = time.monotonic()
frames = 0
while True:
# Read bytes from PyAudio stream, convert to int16, process
# via NumPy's FFT function...
data_8 = self.stream.read(FFT_SIZE * 2, exception_on_overflow=False)
data_16 = np.frombuffer(data_8, np.int16)
fft_out = np.fft.fft(data_16, norm="ortho")
# fft_out will have FFT_SIZE * 2 elements, mirrored at center
# Get spectrum of first half. Instead of square root for
# magnitude, use something between square and cube root.
# No scientific reason, just looked good.
spec_y = [
(c.real * c.real + c.imag * c.imag) ** 0.4 for c in fft_out[0:FFT_SIZE]
]
self.clear() # Clear canvas before drawing
for row in range(ROWS): # Low to high freq...
# Weigh & sum up all the FFT outputs affecting this row
total = 0
for idx, weight in enumerate(self.bin_weight[row]):
total += (spec_y[self.low_bin[row] + idx]) * weight
total /= self.bin_sum[row]
# Auto-leveling is intended to make each column 'pop'.
# When a particular column isn't getting a lot of input
# from the FFT, gradually boost that column's sensitivity.
if total > autolevel[row]: # New level is louder
# Make autolevel rise quickly if column total exceeds it
autolevel[row] = autolevel[row] * 0.25 + total * 0.75
else: # New level is softer
# And fall slowly otherwise
autolevel[row] = autolevel[row] * 0.98 + total * 0.02
# Autolevel limit keeps things from getting TOO boosty.
# Trial and error, no science to this number.
autolevel[row] = max(autolevel[row], 20)
# Apply autoleveling to weighted input.
# This is the prelim. row width before further filtering...
total *= 18 / autolevel[row] # 18 is 1/2 display width
# ...then filter the column width computed above
if total > width[row]:
# If it's greater than this column's current width,
# move column's width quickly in that direction
width[row] = width[row] * 0.3 + total * 0.7
else:
# If less, move slowly down
width[row] = width[row] * 0.5 + total * 0.5
# Compute "peak dots," which sort of show the recent
# peak level for each column (mostly just neat to watch).
if width[row] > peak[row]:
# If column exceeds old peak, move peak immediately,
# give it a slight upward boost.
dropv[row] = (peak[row] - width[row]) * 0.07
peak[row] = min(width[row], 18)
else:
# Otherwise, peak gradually accelerates down
dropv[row] += 0.2
peak[row] -= dropv[row]
# Draw bar for this row. It's done as a gradient,
# bright toward center, dim toward edge.
iwidth = int(width[row] + 0.5) # Integer width
drow = ROWS - 1 - row # Display row, reverse of freq row
if iwidth > 0:
iwidth = min(iwidth, 18) # Clip to 18 pixels
scale = self.brightness * iwidth / 18 # Center brightness
for col in range(iwidth):
level = int(scale * ((1.0 - col / iwidth) ** 2.6))
self.draw.point([17 - col, drow], fill=level)
self.draw.point([18 + col, drow], fill=level)
# Draw peak dot
if peak[row] > 0:
col = int(peak[row] + 0.5)
self.draw.point([17 - col, drow], fill=self.brightness)
self.draw.point([18 + col, drow], fill=self.brightness)
# Update matrices and show est. frames/second
self.redraw()
frames += 1
elapsed = time.monotonic() - start_time
print(frames / elapsed)
if __name__ == "__main__":
MY_APP = AudioSpectrum() # Instantiate class, calls __init__() above
MY_APP.process()

View file

@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Chaser lights for Little Connection Machine. Random bit patterns shift
left or right in groups of four rows. Nothing functional, just looks cool.
Inspired by Jurassic Park's blinky CM-5 prop. As it's a self-contained demo
and not connected to any system services or optional installations, this is
the simplest of the Little Connection Machine examples, making it a good
starting point for your own projects...there's the least to rip out here!
"""
import random
import time
from cm1 import CM1
DENSITY = 30 # Percentage of bits to set (0-100)
FPS = 6 # Frames/second to update (roughly)
def randbit(bitpos):
"""Return a random bit value based on the global DENSITY percentage,
shifted into position 'bitpos' (typically 0 or 8 for this code)."""
return (random.randint(1, 100) > (100 - DENSITY)) << bitpos
class Chaser(CM1):
"""Purely decorative chaser lights for Little Connection Machine."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # CM1 base initialization
# Initialize all bits to 0. 32 rows, 4 columns of 9-bit patterns.
self.bits = [[0 for _ in range(4)] for _ in range(32)]
def run(self):
"""Main loop for Little Connection Machine chaser lights."""
last_redraw_time = time.monotonic()
interval = 1 / FPS # Frame-to-frame time
while True:
self.clear() # Clear PIL self.image, part of the CM1 base class
for row in range(self.image.height): # For each row...
for col in range(4): # For each of 4 columns...
# Rows operate in groups of 4. Some shift left, others
# shift right. Empty spots are filled w/random bits.
if row & 4:
self.bits[row][col] = (self.bits[row][col] >> 1) + randbit(8)
else:
self.bits[row][col] = (self.bits[row][col] << 1) + randbit(0)
# Draw new bit pattern into image...
xoffset = col * 9
for bit in range(9):
mask = 0x100 >> bit
if self.bits[row][col] & mask:
# self.draw is PIL draw object in CM1 base class
self.draw.point([xoffset + bit, row], fill=self.brightness)
# Dillydally to roughly frames/second refresh. Preferable to
# time.sleep() because bit-drawing above isn't deterministic.
while (time.monotonic() - last_redraw_time) < interval:
pass
last_redraw_time = time.monotonic()
self.redraw()
if __name__ == "__main__":
MY_APP = Chaser() # Instantiate class, calls __init__() above
MY_APP.process()

View file

@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Base class for Little Connection Machine projects. Allows drawing on a
single PIL image spanning eight IS31fl3731 Charlieplex matrices and handles
double-buffered updates. Matrices are arranged four across, two down, "the
tall way" (9 pixels across, 16 down) with I2C pins at the bottom.
IS31fl3731 can be jumpered for one of four addresses. Because this uses
eight, two groups are split across a pair of "soft" I2C buses by adding this
to /boot/config.txt:
dtoverlay=i2c-gpio,bus=2,i2c_gpio_scl=17,i2c_gpio_sda=27,i2c_gpio_delay_us=1
dtoverlay=i2c-gpio,bus=3,i2c_gpio_scl=23,i2c_gpio_sda=24,i2c_gpio_delay_us=1
And run:
pip3 install adafruit-extended-bus adafruit-circuitpython-is31fl3731
The extra buses will be /dev/i2c-2 and /dev/i2c-3. These are not as fast as
the "true" I2C bus, but are adequate for this application.
"""
import argparse
import signal
import sys
from PIL import Image
from PIL import ImageDraw
from adafruit_extended_bus import ExtendedI2C as I2C
from adafruit_is31fl3731.matrix import Matrix as Display
DEFAULT_BRIGHTNESS = 40
class CM1:
"""A base class for Little Connection Machine projects, handling common
functionality like LED matrix init, updates and signal handler."""
# pylint: disable=unused-argument
def __init__(self, *args, **kwargs):
self.brightness = DEFAULT_BRIGHTNESS
parser = argparse.ArgumentParser()
parser.add_argument(
"-b",
action="store",
help="Brightness, 0-255. Default: %d" % DEFAULT_BRIGHTNESS,
default=42,
type=int,
)
args = parser.parse_args()
if args.b:
self.brightness = min(max(args.b, 0), 255)
i2c = [
I2C(2), # Extended bus on 17, 27 (clock, data)
I2C(3), # Extended bus on 23, 24 (clock, data)
]
self.display = [
Display(i2c[0], address=0x74, frames=(0, 1)), # Upper row
Display(i2c[0], address=0x75, frames=(0, 1)),
Display(i2c[0], address=0x76, frames=(0, 1)),
Display(i2c[0], address=0x77, frames=(0, 1)),
Display(i2c[1], address=0x74, frames=(0, 1)), # Lower row
Display(i2c[1], address=0x75, frames=(0, 1)),
Display(i2c[1], address=0x76, frames=(0, 1)),
Display(i2c[1], address=0x77, frames=(0, 1)),
]
self.image = Image.new("L", (9 * 4, 16 * 2))
self.draw = ImageDraw.Draw(self.image)
self.frame_index = 0 # Front/back buffer index
signal.signal(signal.SIGTERM, self.signal_handler) # Kill signal
def run(self):
"""Placeholder. Override this in subclass."""
# pylint: disable=unused-argument
def signal_handler(self, signum, frame):
"""Signal handler. Clears all matrices and exits."""
self.clear()
self.redraw()
sys.exit(0)
def clear(self):
"""Clears PIL image. Does not invoke refresh(), just clears."""
self.draw.rectangle([0, 0, self.image.size[0], self.image.size[1]], fill=0)
def redraw(self):
"""Update matrices with PIL image contents, swap buffers."""
# First pass crops out sections over the overall image, rotates
# them to matrix space, and writes this data to each matrix.
for num, display in enumerate(self.display):
col = (num % 4) * 9
row = (num // 4) * 16
cropped = self.image.crop((col, row, col + 9, row + 16))
cropped = cropped.rotate(angle=-90, expand=1)
display.image(cropped, frame=self.frame_index)
# Swapping frames is done in a separate pass so they all occur
# close together, no conspicuous per-matrix refresh.
for display in self.display:
display.frame(self.frame_index, show=True) # True = show frame
self.frame_index ^= 1 # Swap frame index
def process(self):
"""Call CM1 subclass run() function, with keyboard interrupt trapping
centralized here so it doesn't need to be implemented everywhere."""
try:
self.run() # In subclass
except KeyboardInterrupt:
self.signal_handler(0, 0) # clear/redraw/exit

View file

@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: 2022 Phillip Burgess for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
CPU load blinkenlights for Little Connection Machine. Random bits flicker
in response to overall processor load across all cores. This relies on the
'psutil' Python module, which should already be present in Raspbian OS,
but if not, install with: pip install psutil
psutil can provide all sorts of system information -- per-core load, network
use, temperature and more -- but this example is meant to be minimal,
understandable and work across different Pi models (even single-core).
Consider it a stepping off point for your own customizations. See psutil
documentation for ideas: https://pypi.org/project/psutil/
Since this program itself comprises part of the overall CPU load, the LEDs
some amount of LEDs will always be blinking. This is normal and by design.
"""
import random
import psutil
from cm1 import CM1
class CPULoad(CM1):
"""Simple CPU load blinkenlights for Little Connection Machine."""
def run(self):
"""Main loop for Little Connection Machine CPU load blinkies."""
while True:
self.clear() # Clear PIL self.image, part of the CM1 base class
blinkyness = 100 - int(psutil.cpu_percent() + 0.5)
for row in range(self.image.height):
for col in range(self.image.width):
if random.randint(1, 100) > blinkyness:
# self.draw is PIL draw object in CM1 base class
self.draw.point([col, row], fill=self.brightness)
self.redraw()
# No delay in this example, just run full-tilt!
if __name__ == "__main__":
MY_APP = CPULoad() # Instantiate class, calls __init__() above
MY_APP.process()