|
| 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() |
0 commit comments