Adafruit_Spectro_Pi/audio.py
2020-01-25 22:20:34 -08:00

205 lines
10 KiB
Python
Executable file

#!/usr/bin/env python
"""
Audio spectrum display for Adafruit Spectro. 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 python3-pyaudio python3-numpy
See the following for ALSA config (use Stretch directions):
learn.adafruit.com/usb-audio-cards-with-a-raspberry-pi/updating-alsa-config
"""
# Gets code to pass both pylint & pylint3:
# pylint: disable=superfluous-parens, no-self-use, too-many-locals, too-many-branches, too-many-statements, import-error
import numpy as np
from PIL import Image
from PIL import ImageDraw
from spectrobase import SpectroBase
import pyaudio
FORMAT = pyaudio.paInt16 # Data format from audio device (16-bit int)
CHANNELS = 1 # Mono's fine for what we're doing
RATE = 32000 # Balance audio quality & frame rate
CHUNK = 512 # Audio samples to read per frame
NOISE = 2 # Subtraced from FFT output to avoid sparkles
LEAST = 50 # Lowest bin of FFT output to use for graph
MOST = 300 # Highest bin (0 to CHUNK-1). Bit above C7-ish
EXPONENT = 2.5 # Column-to-FFT-bin nonlinear mapping
class AudioSpectrum(SpectroBase):
"""Audio spectrum display for Adafruit Spectro."""
# Not currently used (no args, no self.* variables)
# def __init__(self, *args, **kwargs):
# super(AudioSpectrum, self).__init__(*args, **kwargs)
def weight(self, pos, center, width):
"""Used for 'weighting' values from the spectrum output into display
columns. Given a position 'pos' along X axis of spectrum out,
compute corresponding Y for cubic curve centered on 'center' with
range +/- 'width'. Returns 0.0 (outside or at very edge of curve)
to 1.0 (peak at center of curve)."""
dist = abs(pos - center) / width # Normalized distance from center
if dist >= 1.0: # Not on curve
return 0.0
dist = 1.0 - dist # Invert dist so 1.0 is at center
return ((3.0 - (dist * 2.0)) * dist) * dist
def run(self):
# Access USB mic via PyAudio
audio = pyaudio.PyAudio()
stream = audio.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
output=False,
frames_per_buffer=CHUNK)
# Create offscreen buffer for graphics
double_buffer = self.matrix.CreateFrameCanvas()
# Create PIL image and drawing context
image = Image.new("RGB", (self.matrix.width, self.matrix.height))
draw = ImageDraw.Draw(image)
# Some tables associated with each column of the display.
# Should probably make a class for this, but for now it's just
# lots of messy variables...
height = [0] * self.matrix.width # Current column height
peak = [0] * self.matrix.width # Recent column peak
dropv = [0.0] * self.matrix.width # Current peak falling speed
autolevel = [32.0] * self.matrix.width # Per-column auto adjust
# Precompute values for each display column...
colors = [] # Again, if I make a per-column class as mentioned
weights = [] # above, these values can go in there. Deadlines happen.
sums = []
for column in range(self.matrix.width):
# Precompute hue. '300' is intentional...don't wrap all the
# way around back to red (360). Purple (300) looks good.
colors.append('hsl(%d, %d%%, %d%%)' %
(column * 300 / self.matrix.width, 100, 50))
# Precompute FFT weightings. Each column of the display
# represents the sum of several FFT bins, applying a cubic
# weighting to each, with a slight overlap between columns.
frac = column / float(self.matrix.width - 1) # 0.0 to 1.0
center = LEAST + (MOST - LEAST) * (frac ** EXPONENT)
# Overlap is a function of distance to the next column.
if column > 0:
dist_column = column - 1 # Use distance from prior column
else:
dist_column = column + 1 # 1st column; use distance to next
frac = dist_column / float(self.matrix.width - 1) # 0.0 to 1.0
dist_center = LEAST + (MOST - LEAST) * (frac ** EXPONENT)
width = 0.75 + abs(center - dist_center) # Cubic curve half-width
column_weights = [] # List of bin weights
total = 0.0 # Sum of weights
left = max(0, int(center - width)) # Clip left
right = min(int(center + width + 1), CHUNK-1) # Clip right
# Compute each FFT bin's contribution to column
for bucket in range(left, right): # ('bin' is a reserved keyword)
weight = self.weight(bucket + 0.5, center, width)
if weight > 0:
# Bin is in range, append as a 2-element list with the
# bin index and the bin's weighting toward the column.
column_weights.append([bucket, weight])
total += weight
weights.append(column_weights) # Weights for this column
sums.append(total) # Sum-of-weights for column
# Now normalize all the column sums, apply an exponential
# scaling function to tone down the lower-frequency bins.
# No scientific reason for this, just looks better on the matrix.
maxsum = max(sums)
for column in range(self.matrix.width):
# Scaling function was arrived at through experimentation;
# no scientific reason to these values, just looked good.
scale = (0.02 + 0.08 *
((column / float(self.matrix.width - 1)) ** 1.8))
scale *= maxsum / sums[column] # Boost 'weak' columns
for weight in weights[column]: # List of bin weights for column
weight[1] *= scale # Scale each bin weight
while True:
# Read bytes from PyAudio stream, convert to int16,
# process via NumPy's FFT function...
data_8 = stream.read(CHUNK * 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 CHUNK * 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:CHUNK]]
# Process and render each column (here's where having
# a Column class would make things a little tidier).
for column in range(self.matrix.width):
# Calculate weighted sum of FFT bins for this column
total = -NOISE
for bin_weight in weights[column]:
total += spec_y[bin_weight[0]] * bin_weight[1]
# 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[column]:
# Make autolevel rise quickly if column total exceeds it
autolevel[column] = autolevel[column] * 0.25 + total * 0.75
else:
# And fall slowly otherwise
autolevel[column] = autolevel[column] * 0.98 + total * 0.02
# Minimum autolevel value is 1/8 matrix height
if autolevel[column] < self.matrix.height / 8.0:
autolevel[column] = self.matrix.height / 8.0
# Apply autoleveling to weighted input...
# this is the preliminary column height before filtering
total = total * (self.matrix.height / autolevel[column])
# Filter the column height computed above
if total > height[column]:
# If it's greater than this column's prior height,
# filter column's height quickly in that direction
height[column] = height[column] * 0.4 + total * 0.6
else:
# If less, filter slowly down
height[column] = height[column] * 0.6 + total * 0.4
# Compute "peak dots," which sort of show the recent
# peak level for each column (mostly just neat to watch).
if height[column] > peak[column]:
# If column exceeds old peak, move peak immediately
peak[column] = min(height[column], self.matrix.height)
dropv[column] = 0.0
else:
# Otherwise, peak gradually accelerates down
dropv[column] += 0.15
peak[column] -= dropv[column]
# Draw peak dot
draw.point([column, 32-peak[column]], fill=(255, 255, 255))
# Draw spectrum column (may occlude dot, is OK)
draw.line([column, 32, column, 32-height[column]],
fill=colors[column])
# Debug stuff for showing autolevel action
#draw.point([column, 32-autolevel[column]], fill=(255, 0, 0))
# Copy PIL image to matrix buffer, swap buffers each frame
double_buffer.SetImage(image)
double_buffer = self.matrix.SwapOnVSync(double_buffer)
# Clear PIL image before next pass
draw.rectangle([0, 0, self.matrix.width - 1,
self.matrix.height - 1], fill=0)
if __name__ == "__main__":
MY_APP = AudioSpectrum() # Instantiate class, calls __init__() above
MY_APP.process() # SpectroBase startup, calls run() above