BUSY.Tedt.org BUSY Bar Apps
← All Apps

Audio Visualizer

Live microphone spectrum on the LEDs: five render styles (bars, mirror, segments, dots, wave) and colour themes.

Updated Aug 16, 2026 · Added Jul 27, 2026 · ↓ 0 package download starts

By maxswinkels · ▲ 0 · Rate and Discuss

How It Works

Live microphone spectrum on the LEDs: five render styles (bars, mirror, segments, dots, wave) and colour themes.

Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.

Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:

python3 app.py

Connect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

app.py
#!/usr/bin/env python3
"""Audio visualizer: live spectrum bars from your microphone.

Live spectrum from the mic in several render styles (bars, mirror, segments,
dots, wave), each recolourable with a theme, plus floating peak-hold caps.

    python3 app.py                        # BUSY Bar over USB (always 10.0.4.20)
    python3 app.py --host 127.0.0.1:8080  # emulator or a Wi-Fi bar
    python3 app.py --style segments --theme fire
    python3 app.py --demo                 # no mic/ffmpeg: cycles every style + theme

Options: --style {bars,mirror,segments,dots,wave}
         --theme {classic,fire,ocean,aurora,rainbow}  --fps N

Each frame is rendered to a single 72x16 image and pushed as one image element.
Individual rectangles cost ~3.6 ms each on the device, so a busy style (segments
can reach 100+ rects) either crawls or trips the element cap; one full-frame
image is a flat ~50 ms regardless, so every style runs smoothly.

Live capture needs macOS with `ffmpeg` installed (`brew install ffmpeg`) and
microphone access; it reads the built-in mic via avfoundation. The --demo mode
needs neither and is handy for a quick look.
"""
import argparse
import json
import math
import shutil
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib

APP = "audio-visualizer"

SAMPLE_RATE = 22050
CHUNK_SAMPLES = 2048
NUM_BANDS = 24
FREQ_MIN = 60.0
FREQ_MAX = 8000.0

# 24 target frequencies, log-spaced from 60 Hz to 8000 Hz
_log_min = math.log(FREQ_MIN)
_log_max = math.log(FREQ_MAX)
BAND_FREQS = [
    math.exp(_log_min + (_log_max - _log_min) * i / (NUM_BANDS - 1))
    for i in range(NUM_BANDS)
]

W, H = 72, 16
DISPLAY_H = H

# Colour themes as vertical gradient stops: (position 0..1, (r, g, b) in 0..1).
# Position 0 is the bottom row of the display, 1 is the top. A bar samples the
# palette from its base up to its current peak, so quiet bands stay in the cool
# low colours and loud bands climb into the hot top colours.
THEMES = {
    "classic": [(0.0, (0.00, 0.78, 0.00)), (0.5, (1.00, 0.78, 0.00)), (1.0, (1.00, 0.12, 0.00))],
    "fire":    [(0.0, (0.45, 0.00, 0.00)), (0.35, (1.00, 0.25, 0.00)), (0.7, (1.00, 0.65, 0.00)), (1.0, (1.00, 1.00, 0.75))],
    "ocean":   [(0.0, (0.00, 0.10, 0.55)), (0.45, (0.00, 0.50, 0.95)), (0.8, (0.00, 0.90, 0.95)), (1.0, (0.80, 1.00, 1.00))],
    "aurora":  [(0.0, (0.00, 0.35, 0.20)), (0.4, (0.00, 0.85, 0.50)), (0.7, (0.20, 0.95, 0.75)), (1.0, (0.65, 0.30, 0.95))],
}
THEME_NAMES = list(THEMES.keys()) + ["rainbow"]

# Render styles: the shape/layout of the visualiser (independent of --theme).
STYLE_NAMES = ["bars", "mirror", "segments", "dots", "wave"]

# Bright cap that floats on top of each bar at its recent peak.
PEAK_COLOR = (255, 255, 255)
# Peak caps fall this many pixels per second, then are scaled to the frame rate.
PEAK_FALL_PER_SEC = 9.0


# ---------------------------------------------------------------------------
# HTTP helpers — push one full-frame image per frame
# ---------------------------------------------------------------------------

def _base(host):
    host = host.replace("http://", "").replace("https://", "").rstrip("/")
    return "http://" + host


def _png(pixels):
    """72x16 flat list of (r, g, b) -> minimal RGBA PNG bytes (stdlib only)."""
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        base = y * W
        for x in range(W):
            r, g, b = pixels[base + x]
            raw += bytes((r, g, b, 255))

    def _chunk(tag, data):
        c = tag + data
        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)

    return (b"\x89PNG\r\n\x1a\n"
            + _chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
            + _chunk(b"IDAT", zlib.compress(bytes(raw), 6))
            + _chunk(b"IEND", b""))


# The device briefly locks an asset while a draw reads it; re-uploading the same
# name too soon returns HTTP 508, so rotate through a few filenames.
_RING = 4
_frame_no = 0


def _post(host, path, data, content_type):
    req = urllib.request.Request(_base(host) + path, data=data, method="POST",
                                 headers={"Content-Type": content_type})
    with urllib.request.urlopen(req, timeout=5) as r:
        return r.getcode()


def show(host, pixels):
    """Upload the frame PNG and draw it as one image. Returns the draw status."""
    global _frame_no
    fn = "frame%d.png" % (_frame_no % _RING)
    _frame_no += 1
    try:
        _post(host, "/api/assets/upload?application_name=%s&file=%s" % (APP, fn),
              _png(pixels), "application/octet-stream")
        body = {"application_name": APP,
                "elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}]}
        return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as e:
        return e.code
    except urllib.error.URLError as e:
        raise RuntimeError(f"push failed: {e}") from e


def _clear(host):
    qs = urllib.parse.urlencode({"application_name": APP})
    req = urllib.request.Request(_base(host) + "/api/display/draw?" + qs, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5) as r:
            return r.getcode()
    except urllib.error.HTTPError as e:
        return e.code
    except urllib.error.URLError as e:
        raise RuntimeError(f"clear failed: {e}") from e


# ---------------------------------------------------------------------------
# Spectrum: Goertzel algorithm (pure Python, no numpy)
# ---------------------------------------------------------------------------

def goertzel_magnitude(samples, freq, sample_rate):
    """Compute the magnitude of a single frequency bin using the Goertzel algorithm."""
    n = len(samples)
    k = freq * n / sample_rate
    omega = 2.0 * math.pi * k / n
    cos_w = math.cos(omega)
    coeff = 2.0 * cos_w
    q1 = 0.0
    q2 = 0.0
    for s in samples:
        q0 = coeff * q1 - q2 + s
        q2 = q1
        q1 = q0
    real = q1 - q2 * math.cos(omega)
    imag = q2 * math.sin(omega)
    return math.sqrt(real * real + imag * imag)


def compute_band_magnitudes(samples):
    """Return list of 24 raw magnitudes, one per band."""
    return [goertzel_magnitude(samples, f, SAMPLE_RATE) for f in BAND_FREQS]


# ---------------------------------------------------------------------------
# Colour / palette
# ---------------------------------------------------------------------------

def _hsv(h, s, v):
    """HSV (all 0..1) -> (r, g, b) in 0..1."""
    i = int(h * 6.0) % 6
    f = h * 6.0 - int(h * 6.0)
    p, q, t = v * (1 - s), v * (1 - f * s), v * (1 - (1 - f) * s)
    return [(v, t, p), (q, v, p), (p, v, t), (p, q, v), (t, p, v), (v, p, q)][i]


def _sample(stops, t):
    """Linearly interpolate a list of (pos, (r, g, b)) gradient stops at t in 0..1."""
    t = max(0.0, min(1.0, t))
    prev = stops[0]
    for stop in stops:
        if t <= stop[0]:
            (p0, c0), (p1, c1) = prev, stop
            span = (p1 - p0) or 1.0
            k = (t - p0) / span
            return tuple(c0[j] + (c1[j] - c0[j]) * k for j in range(3))
        prev = stop
    return stops[-1][1]


def _theme_rgb(band_i, t, theme, num_bands):
    """Single (r, g, b) in 0..1 for a band at absolute height fraction t in 0..1."""
    t = max(0.0, min(1.0, t))
    if theme == "rainbow":
        return _hsv(band_i / max(1, num_bands), 1.0, 0.35 + 0.65 * t)
    return _sample(THEMES.get(theme, THEMES["classic"]), t)


def _c(band_i, t, theme, n):
    """Theme colour as an (r, g, b) 0..255 tuple."""
    r, g, b = _theme_rgb(band_i, t, theme, n)
    return (max(0, min(255, int(r * 255 + 0.5))),
            max(0, min(255, int(g * 255 + 0.5))),
            max(0, min(255, int(b * 255 + 0.5))))


# ---------------------------------------------------------------------------
# Pixel buffer + per-style rasterisers (write pixels, not rect elements)
# ---------------------------------------------------------------------------

def _blank():
    return [(0, 0, 0)] * (W * H)


def _px(buf, x, y, rgb):
    if 0 <= x < W and 0 <= y < H:
        buf[y * W + x] = rgb


def _col2(buf, x, y, rgb):
    _px(buf, x, y, rgb)
    _px(buf, x + 1, y, rgb)


def _raster_bars(buf, heights, peaks, theme):
    """Vertical gradient bars anchored at the bottom, floating peak-hold caps."""
    n = len(heights)
    for i, h in enumerate(heights):
        x = i * 3
        for y in range(H - h, H):
            _col2(buf, x, y, _c(i, (H - 1 - y) / (H - 1), theme, n))
        ph = int(peaks[i])
        if ph > h and ph > 0:
            _col2(buf, x, H - ph, PEAK_COLOR)


def _raster_mirror(buf, heights, peaks, theme):
    """Bars grow symmetrically from the horizontal centre, up and down."""
    n = len(heights)
    mid = H // 2
    for i, h in enumerate(heights):
        x = i * 3
        half = h // 2
        for y in range(mid - half, mid):
            t = (mid - y) / max(1, half) * (h / H)
            _col2(buf, x, y, _c(i, t, theme, n))
        for y in range(mid, mid + half):
            t = (y - mid + 1) / max(1, half) * (h / H)
            _col2(buf, x, y, _c(i, t, theme, n))
        phalf = int(peaks[i]) // 2
        if phalf > half and phalf > 0:
            _col2(buf, x, mid - phalf, PEAK_COLOR)
            _col2(buf, x, mid + phalf - 1, PEAK_COLOR)


# LED-block geometry for the segmented style: 2 px lit block, 1 px dark gap.
SEG_BLOCK, SEG_GAP = 2, 1
SEG_PITCH = SEG_BLOCK + SEG_GAP
SEG_SLOTS = 6


def _seg_block(k):
    """(y, height) of block slot k (0 = bottom), clamped into the display."""
    y = H - SEG_BLOCK - k * SEG_PITCH
    bh = SEG_BLOCK
    if y < 0:
        bh += y
        y = 0
    return y, bh


def _raster_segments(buf, heights, peaks, theme):
    """Discrete stacked LED blocks per band (classic hardware VU meter)."""
    n = len(heights)
    for i, h in enumerate(heights):
        x = i * 3
        lit = int(round(h / H * SEG_SLOTS))
        for k in range(lit):
            y, bh = _seg_block(k)
            if bh <= 0:
                continue
            t = 1.0 - (y + bh / 2.0) / H
            col = _c(i, t, theme, n)
            for yy in range(y, y + bh):
                _col2(buf, x, yy, col)
        peak_slot = int(round(peaks[i] / H * SEG_SLOTS))
        if peak_slot > lit and peak_slot > 0:
            y, bh = _seg_block(peak_slot - 1)
            for yy in range(y, y + bh):
                _col2(buf, x, yy, PEAK_COLOR)


def _raster_dots(buf, heights, peaks, theme):
    """Only a bouncing dot at each band top, with a falling peak-hold dot."""
    n = len(heights)
    for i, h in enumerate(heights):
        x = i * 3
        if h > 0:
            dh = 2
            y0 = min(H - dh, H - h)
            col = _c(i, h / H, theme, n)
            for yy in range(y0, y0 + dh):
                _col2(buf, x, yy, col)
        ph = int(peaks[i])
        if ph > h and ph > 0:
            _col2(buf, x, H - ph, PEAK_COLOR)


def _raster_wave(buf, heights, peaks, theme):
    """A continuous contour line connecting the band tops (oscilloscope-style)."""
    n = len(heights)
    tops = [H - max(1, h) for h in heights]
    for i, h in enumerate(heights):
        x = i * 3
        col = _c(i, max(1, h) / H, theme, n)
        _col2(buf, x, tops[i], col)
        if i < n - 1:
            lo, hi = min(tops[i], tops[i + 1]), max(tops[i], tops[i + 1])
            for yy in range(lo, hi + 1):
                _px(buf, x + 2, yy, col)


_RASTERISERS = {
    "bars": _raster_bars,
    "mirror": _raster_mirror,
    "segments": _raster_segments,
    "dots": _raster_dots,
    "wave": _raster_wave,
}


def build_pixels(heights, peaks, theme, style="bars"):
    """Rasterise the chosen style into a flat 72x16 pixel buffer."""
    buf = _blank()
    _RASTERISERS.get(style, _raster_bars)(buf, heights, peaks, theme)
    return buf


def magnitudes_to_heights(magnitudes, running_max):
    """Convert raw magnitudes to pixel heights 0..16, updating running_max in place."""
    heights = []
    for mag in magnitudes:
        running_max[0] = max(running_max[0] * 0.995, 1.0)
        if mag > running_max[0]:
            running_max[0] = mag
        h = int(16.0 * math.log1p(mag) / math.log1p(running_max[0]))
        heights.append(max(0, min(16, h)))
    return heights


def smooth_heights(new_heights, old_heights, decay=0.75):
    """Attack/decay smoothing: bars fall at decay rate per frame."""
    return [max(n, int(o * decay)) for n, o in zip(new_heights, old_heights)]


# ---------------------------------------------------------------------------
# argparse
# ---------------------------------------------------------------------------

def parse_args():
    p = argparse.ArgumentParser(description="Audio visualizer for BUSY Bar")
    p.add_argument("--host", default="10.0.4.20")
    p.add_argument("--fps", type=int, default=15, help="draw updates per second (default: 15)")
    p.add_argument("--style", default="bars", choices=STYLE_NAMES,
                   help="render style / shape (default: bars)")
    p.add_argument("--theme", default="classic", choices=THEME_NAMES,
                   help="colour theme (default: classic)")
    p.add_argument("--device", default="auto", help="ffmpeg avfoundation audio input, e.g. :1 (default: auto)")
    p.add_argument("--demo", action="store_true",
                   help="run a synthetic-audio demo (no mic/ffmpeg) that cycles the styles and themes")
    p.add_argument("--test", action="store_true")
    return p.parse_args()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def _test_mode(host, theme, style):
    """Draw one synthetic frame and exit."""
    heights = [3, 5, 8, 12, 16, 14, 10, 7, 9, 13, 15, 11, 8, 6, 4, 7, 10, 14, 12, 9, 6, 4, 3, 2]
    peaks = [min(H, h + 3) for h in heights]
    status = show(host, build_pixels(heights, peaks, theme, style))
    print(f"test: drew 1 frame ({style}/{theme}) status {status}")
    sys.exit(0)


# One (style, theme) pair per style so a viewer sees every look once.
DEMO_COMBOS = [
    ("bars", "fire"),
    ("mirror", "aurora"),
    ("segments", "classic"),
    ("dots", "ocean"),
    ("wave", "rainbow"),
]
DEMO_SECONDS_PER_COMBO = 1.6

# ~100 BPM groove; the whole cycle equals len(DEMO_COMBOS) * DEMO_SECONDS_PER_COMBO.
_DEMO_BEAT_HZ = 100.0 / 60.0


def _synth_heights(t):
    """Music-like spectrum for band 0..23 at time t seconds (no audio needed)."""
    phase = (t * _DEMO_BEAT_HZ) % 1.0
    kick = math.exp(-6.0 * phase)
    off = (phase + 0.5) % 1.0
    snare = math.exp(-9.0 * off)
    swell = 0.85 + 0.15 * math.sin(t * 0.5)
    mel_center = 6.0 + 8.0 * (0.5 + 0.5 * math.sin(t * 0.8))

    heights = []
    for i in range(NUM_BANDS):
        if i <= 5:
            wob = 0.5 + 0.5 * math.sin(t * 2.0 + i * 0.7)
            val = (0.55 + 0.45 * wob) * (0.30 + 0.80 * kick)
        elif i <= 14:
            mel = math.exp(-((i - mel_center) ** 2) / 6.0)
            groove = 0.5 + 0.5 * math.sin(t * 3.3 + i * 0.5)
            val = 0.20 + 0.75 * mel * (0.4 + 0.6 * groove) + 0.20 * snare
        else:
            shimmer = 0.5 + 0.5 * math.sin(t * 11.0 + i * 1.7)
            hat = math.exp(-12.0 * off)
            val = (0.10 + 0.35 * shimmer) * (0.5 + 0.9 * hat)
        val *= swell
        heights.append(max(0, min(H, int(round(val * H)))))
    return heights


def _demo_mode(host, fps):
    """Drive the real render pipeline with a synthetic spectrum, cycling looks."""
    interval = 1.0 / max(1, fps)
    peak_fall = max(0.3, PEAK_FALL_PER_SEC / max(1, fps))
    smooth = [0] * NUM_BANDS
    peaks = [0.0] * NUM_BANDS
    t0 = time.monotonic()
    print(f"audio-visualizer demo -> {_base(host)}  (Ctrl-C to stop)")
    try:
        while True:
            t = time.monotonic() - t0
            style, theme = DEMO_COMBOS[int(t / DEMO_SECONDS_PER_COMBO) % len(DEMO_COMBOS)]
            new_h = _synth_heights(t)
            smooth = smooth_heights(new_h, smooth)
            for i in range(NUM_BANDS):
                peaks[i] = max(float(smooth[i]), peaks[i] - peak_fall)
            try:
                show(host, build_pixels(smooth, peaks, theme, style))
            except RuntimeError:
                pass
            time.sleep(interval)
    except KeyboardInterrupt:
        pass
    finally:
        try:
            _clear(host)
        except Exception:
            pass
        print("stopped.")


def pick_device():
    """List avfoundation audio devices and pick the built-in microphone:
    prefer a name containing MacBook, else the first non-iPhone device."""
    r = subprocess.run(["ffmpeg", "-hide_banner", "-f", "avfoundation",
                        "-list_devices", "true", "-i", ""],
                       capture_output=True, text=True, timeout=10)
    devices = []
    in_audio = False
    for line in r.stderr.splitlines():
        if "audio devices" in line.lower():
            in_audio = True
            continue
        if in_audio:
            m = __import__("re").search(r"\[(\d+)\] (.+)$", line)
            if m:
                devices.append((m.group(1), m.group(2).strip()))
            elif "]" not in line:
                break
    if not devices:
        return ":0"
    for idx, name in devices:
        if "macbook" in name.lower():
            print(f"microfoon: [{idx}] {name}")
            return f":{idx}"
    for idx, name in devices:
        if "iphone" not in name.lower():
            print(f"microfoon: [{idx}] {name}")
            return f":{idx}"
    return f":{devices[0][0]}"


def main():
    args = parse_args()

    if args.test:
        _test_mode(args.host, args.theme, args.style)
        return

    if args.demo:
        _demo_mode(args.host, args.fps)
        return

    if shutil.which("ffmpeg") is None:
        print("ffmpeg not found. Install it with: brew install ffmpeg")
        sys.exit(1)

    frame_interval = 1.0 / max(1, args.fps)
    peak_fall = max(0.3, PEAK_FALL_PER_SEC / max(1, args.fps))
    running_max = [1.0]
    smooth = [0] * NUM_BANDS
    peaks = [0.0] * NUM_BANDS
    last_draw = 0.0
    last_chunk = None
    ffmpeg_proc = None
    _draw_error_printed = False

    if args.device == "auto":
        try:
            args.device = pick_device()
        except Exception as e:
            print(f"device detection failed ({e}), using :0")
            args.device = ":0"

    cmd = [
        "ffmpeg",
        "-hide_banner", "-loglevel", "error",
        "-f", "avfoundation",
        "-i", args.device,
        "-ac", "1",
        "-ar", str(SAMPLE_RATE),
        "-f", "s16le",
        "-",
    ]

    print(f"audio-visualizer -> {_base(args.host)}  (Ctrl-C to stop)")

    try:
        ffmpeg_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

        while True:
            chunk_bytes = ffmpeg_proc.stdout.read(CHUNK_SAMPLES * 2)
            if chunk_bytes and len(chunk_bytes) == CHUNK_SAMPLES * 2:
                last_chunk = chunk_bytes
                _hb_count = globals().setdefault("_hb", [0])
                _hb_count[0] += 1
                if _hb_count[0] % 22 == 0:
                    samples = struct.unpack(f"<{CHUNK_SAMPLES}h", chunk_bytes)
                    rms = (sum(v * v for v in samples) / CHUNK_SAMPLES) ** 0.5
                    note = "" if rms >= 20 else "  (stilte: check microfoontoestemming)"
                    print(f"audio niveau: {rms:7.1f}{note}")
            elif ffmpeg_proc.poll() is not None:
                err = ffmpeg_proc.stderr.read().decode("utf-8", "ignore").strip()
                print("ffmpeg stopped unexpectedly:")
                for line in err.splitlines()[-5:]:
                    print(f"  {line}")
                sys.exit(1)

            now = time.monotonic()
            if now - last_draw >= frame_interval:
                last_draw = now
                if last_chunk is not None:
                    samples = list(struct.unpack(f"<{CHUNK_SAMPLES}h", last_chunk))
                    mags = compute_band_magnitudes(samples)
                    new_h = magnitudes_to_heights(mags, running_max)
                    smooth = smooth_heights(new_h, smooth)
                    for i in range(NUM_BANDS):
                        peaks[i] = max(float(smooth[i]), peaks[i] - peak_fall)
                    try:
                        status = show(args.host, build_pixels(smooth, peaks, args.theme, args.style))
                        if status == 409:
                            if not _draw_error_printed:
                                print("display busy (409), retrying")
                                _draw_error_printed = True
                        else:
                            _draw_error_printed = False
                    except RuntimeError as e:
                        if not _draw_error_printed:
                            print(f"draw error: {e}")
                            _draw_error_printed = True

    except KeyboardInterrupt:
        pass
    finally:
        if ffmpeg_proc is not None:
            ffmpeg_proc.terminate()
            try:
                ffmpeg_proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                ffmpeg_proc.kill()
        try:
            _clear(args.host)
        except Exception:
            pass
        print("stopped.")


if __name__ == "__main__":
    main()
manifest.yaml
name: Audio Visualizer
author: maxswinkels
description: "Live microphone spectrum on the LEDs: five render styles (bars, mirror, segments, dots, wave) and colour themes."
tags:
  - audio
  - effect
  - music
preview: ./preview.gif

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: dcb79ed8edd615e9ddd4f5a351ddf9abc7ef8224b412d9d7473ddf8a273cee98

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.