Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
| ![fonts.png](fonts.png) | ![framebuf_simpletest.png](framebuf_simpletest.png) | ![noto_fonts.png](noto_fonts.png) |
| ![paint.png](paint.png) | ![proverbs.png](proverbs.png) | ![testris.png](testris.png) |
| ![tiny_toasters.gif](tiny_toasters.gif) | ![piano.png](piano.png) | ![google_photos.png](google_photos.png) |
| ![google_photos_view.png](google_photos_view.png) | ![google_photos_connect.png](google_photos_connect.png) | |
| ![google_photos_view.png](google_photos_view.png) | ![google_photos_connect.png](google_photos_connect.png) | ![spectrum_800x480.gif](spectrum_800x480.gif) |
| ![spectrum_800x480.png](spectrum_800x480.png) | ![spectrum_800x480_segmented.png](spectrum_800x480_segmented.png) | ![spectrum_320x170.png](spectrum_320x170.png) |

On Android (the PyDevices Runner) and as an installed PWA:

Expand Down
Binary file added docs/screenshots/spectrum_320x170.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/spectrum_800x480.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/spectrum_800x480.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/spectrum_800x480_segmented.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/spectrum_p4_music.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 92 additions & 0 deletions lib/examples/spectrum/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# spectrum

A spectrum analyzer that is built to look good rather than to measure
anything. It draws log-spaced bars from 20 Hz to 20 kHz in a cool gradient,
with peak caps that hang and then fall, a faint reflection under the baseline,
and frequency labels along the bottom. On a board running usbif's USB sound
card it shows the audio the PC is playing. The levels are computed in C, in
the sound card's pump. Everywhere else it plays fake music.

![800x480](../../../docs/screenshots/spectrum_800x480.gif)

| 800x480, segmented | 320x170 (T-Embed) |
|:--:|:--:|
| ![segmented](../../../docs/screenshots/spectrum_800x480_segmented.png) | ![320x170](../../../docs/screenshots/spectrum_320x170.png) |

## Run it

From `lib/`:

```bash
micropython examples/spectrum/spectrum.py
SPECTRUM_STYLE=segmented micropython examples/spectrum/spectrum.py
PYDEVICES_WIDTH=320 PYDEVICES_HEIGHT=170 PYDEVICES_SCALE=3 micropython examples/spectrum/spectrum.py
```

On desktop the window opens at 800x480. On a board, the example takes its size
from the panel. It prints the frame rate and the cost of each part of a frame
every five seconds.

## What's where

`spectrum_view.py` does the drawing and knows nothing about audio. Hand
`SpectrumView.update()` one level per band, from 0 to 1, and it handles the
ballistics itself: bars rise fast and fall at a steady rate, and the peak caps
hold for about half a second before dropping with gravity. The palette, band
count, timings and colours are constants at the top of the file.

`fake_music.py` is the stand-in source: a four-bar loop at 120 BPM with kick,
snare, hats, bass, a pad and a lead, plus a dead stop and a breakdown so you
can watch the fall-off. Its docstring has the arrangement. The loop is seeded,
so every run is the same.

`capture.py` renders headless under MicroPython and reports timings, and
`make_captures.py` (CPython with Pillow) turns those renders into the images
in `docs/screenshots/`.

## How it stays cheap

Every bar is a single `blit` from a gradient column that was built once, so a
tall bar costs the same as a short one. The segment gaps and the reflection's
scanlines use a colour key, so the grid shows through them. The static art
(background, grid, labels) is painted once into a second framebuffer. Each
frame copies back only the row strip the bars could have touched, and that
strip is all that goes to the panel.

On desktop MicroPython at 800x480 with 48 bands, a frame costs about 0.2 ms to
generate the data, 0.7–1.5 ms to draw and 0.9 ms to blit to the SDL window.
The timer caps it at 50 fps.

## On the ESP32-P4 panel, beside the sound card

![The P4 panel's framebuffer, music playing](../../../docs/screenshots/spectrum_p4_music.png)

This needs a firmware with usbif's meter ([usbif#50](https://github.com/PyDevices/usbif/pull/50)),
which adds `uac_pump_meter()` and `uac_pump_levels()`. Import `spectrum` before
running `soundcard.py`, and the meter draws from a timer while the sound card
runs. `pump_levels.py` is the real source.

What the measurements found (2026-09-25):

- **Drawing costs no USB packets, once one setting changes.** A cache
writeback (`esp_cache_msync`) runs with interrupts off, and syncing the
panel held the USB interrupt off long enough to drop delivery to
98.7-99.4 %, under the 99.5 % gate. With the writeback sliced
(`CONFIG_ESP_MM_CACHE_MSYNC_C2M_CHUNKED_OPS`), meter on and meter off match
to within 0.03 %: 99.75-99.88 % against 99.77-99.86 %. The P4 panel's board
definition carries that setting since micropython-pydevices#23.
- **The sound card's own baseline** settles near 99.8 % a window or two into a
48 kHz stream, with or without the meter. A 24 kHz wire holds 100 %.
- **The cost in the pump** is 1.05 % of a 360 MHz core for the feed plus
3.2-3.8 % for the analysis: two FFTs, 60 a second, with the longest single
analysis about 1 ms.
- **The frame rate** is 50 fps in silence, 27-29 with every bar moving and 19
with loud music. Each bar is two prebuilt columns, only the rows that moved
are copied, and one band is presented a frame (`SpectrumView.render_columns`).
- **The low end, from a full-range track:** the 22, 26 and 30 Hz bars sit near
empty. From 35 Hz up, every bar moves. The top four bars (10-20 kHz) also
hover near the floor.

[`tools/spectrum/meter_gate.py`](../../../tools/spectrum/meter_gate.py) (board) and
[`tools/spectrum/play_src.py`](../../../tools/spectrum/play_src.py) (Windows Python)
reproduce the numbers.
7 changes: 7 additions & 0 deletions lib/examples/spectrum/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import sys

_wd = __file__.replace("\\", "/")
_wd = _wd.rsplit("/", 1)[0] if "/" in _wd else "."
if _wd not in sys.path:
sys.path.insert(0, _wd)
from . import spectrum # noqa: F401 -- gallery/kit entry: import spectrum
79 changes: 79 additions & 0 deletions lib/examples/spectrum/capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""
capture.py -- render the analyzer headless, for screenshots and timing.

Runs on MicroPython (or CPython) with no display. Renders ``frames`` frames at
a fixed ``fps`` of fake-music time, prints what each part of a frame costs,
and, given an output path, appends every frame from ``start`` on to it as raw
RGB565 (little-endian, width x height per frame)::

micropython capture.py WIDTH HEIGHT FRAMES FPS [STYLE] [OUT.raw] [START]

``make_captures.py`` drives this and turns the raw frames into PNGs and a GIF.
"""

import sys

try:
from time import ticks_diff, ticks_us
except ImportError:
from time import perf_counter

def ticks_us():
return int(perf_counter() * 1e6)

def ticks_diff(a, b):
return a - b


_here = __file__.replace("\\", "/").rsplit("/", 1)[0] if "/" in __file__ else "."
if _here not in sys.path:
sys.path.insert(0, _here)

from fake_music import FakeMusic # noqa: E402
from spectrum_view import SpectrumView # noqa: E402

args = sys.argv[1:]
width, height, frames, fps = int(args[0]), int(args[1]), int(args[2]), int(args[3])
style = args[4] if len(args) > 4 else "smooth"
out = args[5] if len(args) > 5 else None
start = int(args[6]) if len(args) > 6 else 0

t0 = ticks_us()
view = SpectrumView(width, height, style=style)
setup_us = ticks_diff(ticks_us(), t0)
music = FakeMusic(view.bands)
dt = 1 / fps
f = open(out, "wb") if out else None
src_us = draw_us = rows = 0
worst = 0
for k in range(frames):
t = k * dt
a = ticks_us()
levels = music.levels(t)
b = ticks_us()
view.update(levels, dt)
y, h = view.render()
c = ticks_us()
src_us += ticks_diff(b, a)
draw_us += ticks_diff(c, b)
worst = max(worst, ticks_diff(c, b))
rows += h
if f and k >= start:
f.write(view.fb.buffer if hasattr(view.fb, "buffer") else view._buf)
if f:
f.close()
print(
"{}x{} {} bands, style {}: setup {:.1f} ms; per frame: fake data {:.2f} ms, "
"draw {:.2f} ms (worst {:.2f}), dirty rows {:.0f} of {}".format(
width,
height,
view.bands,
style,
setup_us / 1000,
src_us / frames / 1000,
draw_us / frames / 1000,
worst / 1000,
rows / frames,
height,
)
)
197 changes: 197 additions & 0 deletions lib/examples/spectrum/fake_music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""
fake_music.py -- band levels that move like a band playing, with no audio.

A stand-in for the real source (band levels published from C where the audio
passes). ``FakeMusic(n).levels(t)`` returns ``n`` levels in 0..1 for time ``t``
seconds, over log-spaced bands from 20 Hz to 20 kHz, mapped from -54..0 dB.

What it plays, at 120 BPM in an 8-second loop (four bars):

* a kick on every beat, a thump around 55 Hz that dies in a tenth of a second;
* a snare on beats 2 and 4, a broad crack centred near 1.5 kHz with some body;
* closed hats on the eighths and an open hat before each bar, fizz above 5 kHz
with a different sparkle on every hit;
* a bass line and a three-note pad following the chords (Am, F, C, G);
* a lead line hopping round a pentatonic scale, with vibrato;
* a pink-ish tilt over the lot, falling about 3 dB an octave above 150 Hz.

Bar two ends in a dead stop (3.5 s to 4.25 s) so the bars and the peak caps
can be seen falling, then a breakdown of pad, lead and hats until 5.5 s, when
the drums and bass come back in.

Deterministic: every loop replays the same numbers from a seeded xorshift, so
a capture is repeatable and loops cleanly.
"""

import math

from spectrum_view import band_centres

BEAT = 0.5 # seconds, 120 BPM
LOOP = 8.0
FLOOR_DB = -54.0

# Chord per bar: bass root, pad notes (Hz).
CHORDS = (
(55.00, (220.0, 261.6, 329.6)), # Am
(43.65, (174.6, 220.0, 261.6)), # F
(65.41, (196.0, 261.6, 329.6)), # C
(49.00, (196.0, 246.9, 293.7)), # G
)
PENTA = (440.0, 523.3, 587.3, 659.3, 784.0, 880.0, 1046.5, 1174.7)


class XorShift:
def __init__(self, seed):
self.s = seed & 0xFFFFFFFF or 1

def next(self):
s = self.s
s ^= (s << 13) & 0xFFFFFFFF
s ^= s >> 17
s ^= (s << 5) & 0xFFFFFFFF
self.s = s
return s

def uniform(self):
return self.next() / 4294967296.0


def _gauss(x):
return math.exp(-x * x)


class FakeMusic:
def __init__(self, bands, seed=2026):
self.n = bands
self.seed = seed
fc = band_centres(bands)
self.oct = [math.log(f / 20.0) / math.log(2) for f in fc] # octaves above 20 Hz
self.bw_oct = math.log(1000) / math.log(2) / bands
# Width of a partial's smear across bands, in octaves.
self.sigma = max(0.16, self.bw_oct * 0.6)
self.tilt = [10 ** (-0.3 * max(0.0, math.log(f / 150.0) / math.log(2))) for f in fc]
# Fixed spectral shapes (linear power per band) of the noisy sounds.
self.snare_w = [
0.5 * _gauss((o - self._o(1500)) / 1.6) + 0.35 * _gauss((o - self._o(200)) / 0.4)
for o in self.oct
]
self.hat_w = [
(1 / (1 + math.exp(-(o - self._o(6000)) * 3.0))) * _gauss((o - self._o(11000)) / 1.4)
for o in self.oct
]
self.room_w = [
_gauss((o - self._o(400)) / 3.0) for o in self.oct
] # reverb wash under everything
self.kick_click_w = [_gauss((o - self._o(3500)) / 0.8) for o in self.oct]
self._p = [0.0] * bands
self._rng = XorShift(seed)
self._loop = -1
self._sparkle = [1.0] * bands
self._hat_n = -1

@staticmethod
def _o(hz):
return math.log(hz / 20.0) / math.log(2)

def _partial(self, hz, power):
"""Add a pitched partial, smeared over the bands within reach of it."""
if hz <= 20 or hz >= 20000 or power <= 0:
return
o = self._o(hz)
sig = self.sigma
centre = o / self.bw_oct - 0.5
reach = int(2.5 * sig / self.bw_oct) + 1
lo = max(0, int(centre) - reach)
hi = min(self.n, int(centre) + reach + 2)
p, oc = self._p, self.oct
for i in range(lo, hi):
d = (oc[i] - o) / sig
p[i] += power * math.exp(-d * d)

def levels(self, t):
n = self.n
p = self._p
for i in range(n):
p[i] = 0.0
loop = int(t // LOOP)
tl = t - loop * LOOP
if loop != self._loop:
self._loop = loop
self._rng = XorShift(self.seed)
self._hat_n = -1
rng = self._rng

stop = 3.5 <= tl < 4.25
breakdown = 4.25 <= tl < 5.5
drums = not stop and not breakdown
bar = int(tl // (4 * BEAT)) % 4
beat_t = tl % BEAT
beat_i = int(tl // BEAT) % 4
root, pad = CHORDS[bar]
out = [FLOOR_DB] * n
if stop:
return [0.0] * n

# Room wash: quiet, keeps the floor alive while the band plays.
room = 0.000006 + 0.000006 * rng.uniform()
for i in range(n):
p[i] += room * self.room_w[i]

if drums:
k = math.exp(-beat_t / 0.09)
kick_hz = 50 + 70 * math.exp(-beat_t / 0.02)
self._partial(kick_hz, 1.0 * k)
self._partial(kick_hz * 2, 0.25 * k)
click = 0.02 * math.exp(-beat_t / 0.01)
for i in range(n):
p[i] += click * self.kick_click_w[i]
if beat_i in (1, 3):
s = 0.18 * math.exp(-beat_t / 0.11)
for i in range(n):
p[i] += s * self.snare_w[i]
# Bass: eighth notes pumping on the chord root, octave on the offbeat.
e_t = tl % (BEAT / 2)
off = int(tl // (BEAT / 2)) & 1
b = 0.35 * (0.25 + 0.75 * math.exp(-e_t / 0.18))
f = root * (2 if off else 1)
for h in range(1, 5):
self._partial(f * h, b / (h * h))

# Hats: closed on the eighths, open on the last offbeat of each bar.
e_n = int(tl // (BEAT / 2))
e_t = tl % (BEAT / 2)
if e_n != self._hat_n:
self._hat_n = e_n
for i in range(n):
self._sparkle[i] = 0.35 + 1.3 * rng.uniform()
open_hat = (e_n % 8) == 7
acc = 1.0 if (e_n & 1) else 0.55
hat = 0.5 * acc * math.exp(-e_t / (0.2 if open_hat else 0.035))
sp = self._sparkle
for i in range(n):
p[i] += hat * self.hat_w[i] * sp[i]

# Pad: swells in on each bar.
bar_t = tl % (4 * BEAT)
swell = 0.02 * (1 - math.exp(-bar_t / 0.4)) * (1.4 if breakdown else 1.0)
for note in pad:
for h in range(1, 4):
self._partial(note * h, swell / (h * h))

# Lead: a new pentatonic note every sixteenth-ish, with vibrato.
step = int(tl / 0.375)
note = PENTA[(step * 5 + bar * 3 + (step >> 2)) % len(PENTA)]
n_t = tl - step * 0.375
vib = 1 + 0.012 * math.sin(2 * math.pi * 5.5 * tl)
lead = 0.05 * (0.35 + 0.65 * math.exp(-n_t / 0.25))
for h in range(1, 6):
self._partial(note * vib * h, lead / h)

# To 0..1 over -60..0 dB, with the tilt applied.
tilt = self.tilt
for i in range(n):
v = p[i] * tilt[i]
if v > 1e-6:
out[i] = 10 * math.log10(v)
return [(d - FLOOR_DB) / -FLOOR_DB for d in out]
Loading
Loading