Spectrogram
A whistled tune as a dithered spectrogram with two faint overtones. The last phrase cuts off at the playhead.
Made with Claude Opus 5.5
- Technique
- dithering, instrument displays
- Shape
- Any screen
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
A spectrogram runs time from left to right and pitch upward, and louder sound is drawn heavier. A whistled note is close to a pure tone, so it shows as one line, with fainter overtones at whole multiples of its pitch.
Sources
Source code
wallpapers/dither-spectrogram/design.py, 93 lines
"""Spectrogram of a whistled tune cut off at a playhead, with dithered falloff."""
import numpy as np
from numpy.typing import NDArray
from scipy.interpolate import CubicSpline
from walldye import (
ACCENT,
ACCENT_4,
ACCENT_6,
ACCENT_7,
BG_ALT,
UI,
UI_HI,
Canvas,
design,
)
from walldye.pixel import Pixels, blue_noise
type Field = NDArray[np.float64]
CELL, ROWS, MARGIN = 3, 126, 96 # pixel cell; frequency rows (378 units); panel's left margin
SPAN = 1920 # canvas units per unit of time: the 16:9 panel shows t = 0.05 .. NOW
NOW = 1299 / 1920 # time at the playhead
SUB = 5 # time samples per column, so a steep sweep fills every row it crosses
CAP = """
#######
.#####.
..###..
...#...
"""
# Phrases of (time, pitch) knots; pitch in rows above the band floor. Gaps between phrases are
# breaths. The first two only show on screens wider than 16:9, where more of the past fits.
PHRASES = [
[(-0.60, 22), (-0.56, 19), (-0.52, 25), (-0.47, 31), (-0.43, 29), (-0.38, 24), (-0.34, 18)],
[(-0.29, 17), (-0.25, 21), (-0.21, 27), (-0.16, 24), (-0.12, 30), (-0.07, 28), (0.02, 19)],
[(0.07, 18), (0.11, 21), (0.15, 28), (0.20, 25), (0.24, 33), (0.29, 31), (0.33, 20)],
[(0.37, 17), (0.41, 23), (0.45, 20), (0.49, 28), (0.53, 26)],
[(0.57, 19), (0.61, 26), (0.66, 34), (0.74, 31)], # still sounding when the capture stops
]
# Palette indices: 1 is the noise floor, 2-5 the accent ramp by strength, 6-7 the playhead.
PALETTE = [None, BG_ALT, ACCENT_7, ACCENT_6, ACCENT_4, ACCENT, UI, UI_HI]
# Per harmonic: (core half-width in rows, falloff width in rows, core index, falloff index).
BANDS = [(1.5, 2.5, 5, 4), (1.0, 2.0, 4, 3), (0.8, 1.6, 3, 2)]
# Fraction of a phrase spent swelling and fading, and the vibrato depth in rows.
RISE, FADE, VIBRATO = 0.06, 0.18, 0.25
def melody(t: Field) -> tuple[Field, Field]:
"""The pitch in rows and the loudness in [0, 1] of the tune at times `t`, any shape;
loudness is 0 between phrases."""
f0, amp = np.zeros(t.shape), np.zeros(t.shape)
for knots in PHRASES:
kt, kp = zip(*knots, strict=True)
on = (t >= kt[0]) & (t <= kt[-1])
pitch = CubicSpline(kt, kp, bc_type="clamped")(t) + VIBRATO * np.sin(t * 420)
f0 = np.where(on, pitch, f0)
u = np.clip((t - kt[0]) / (kt[-1] - kt[0]), 0, 1)
amp = np.where(on, np.minimum(1, u / RISE) * np.minimum(1, (1 - u) / FADE) ** 0.7, amp)
return f0, amp
@design(aspects="any")
def draw(s: Canvas) -> None:
# the playhead's top, two thirds across with the future open to its right; a narrower
# screen shows less of the past, and a portrait one holds the panel in its upper third
head = s.pick(landscape=(NOW, 312 / 1080), portrait=(NOW, 0.3), snap=CELL)
cols = round(head.x - MARGIN) // CELL # panel columns; the playhead is the next one
# (SUB, cols) sample times across each column; only the past, up to the playhead, is drawn
x = MARGIN + (np.arange(cols) + (np.arange(SUB)[:, None] + 0.5) / SUB) * CELL
f0, amp = melody(NOW + (x - head.x) / SPAN)
on = amp > 0
track = (np.where(on, f0, np.inf).min(axis=0), np.where(on, f0, -np.inf).max(axis=0))
level = amp[SUB // 2] # loudness at the column centre
f = np.arange(ROWS)[::-1][:, None] + 0.5 # row 0 is the top (high frequency)
thr = np.tile(blue_noise(64, s.np_rng(5)), (ROWS // 64 + 1, cols // 64 + 1))[:ROWS, :cols]
px = Pixels(cols + 4, ROWS + 4, PALETTE) # spare rows above and columns right for the cap
grid = px.grid[4:, :cols]
grid[thr < 0.05] = 1 # noise floor: sparse specks, ~5% coverage
# stroke thickness follows the note envelope; dither only in the thin falloff
live = level > 0.02
for h, (core, fall, ci, fi) in enumerate(BANDS, start=1):
d = np.maximum(np.maximum(h * track[0] - f, f - h * track[1]), 0) - core * level
halo = live & (d > 0) & (thr < np.exp(-((d / fall) ** 2)) * level * 0.7)
grid[halo & (grid < fi)] = fi
body = live & (d <= 0) & (level * core > 0.35)
grid[body] = np.maximum(grid[body], ci)
px.grid[4:, cols] = 6
px.stamp(CAP, cols - 3, 0, {"#": 7})
px.draw(s, CELL, (MARGIN, head.y - 4 * CELL))"""Spectrogram of a whistled tune cut off at a playhead, with dithered falloff."""
import numpy as np
from numpy.typing import NDArray
from scipy.interpolate import CubicSpline
from walldye import (
ACCENT,
ACCENT_4,
ACCENT_6,
ACCENT_7,
BG_ALT,
UI,
UI_HI,
Canvas,
design,
)
from walldye.pixel import Pixels, blue_noise
type Field = NDArray[np.float64]
CELL, ROWS, MARGIN = 3, 126, 96 # pixel cell; frequency rows (378 units); panel's left margin
SPAN = 1920 # canvas units per unit of time: the 16:9 panel shows t = 0.05 .. NOW
NOW = 1299 / 1920 # time at the playhead
SUB = 5 # time samples per column, so a steep sweep fills every row it crosses
CAP = """
#######
.#####.
..###..
...#...
"""
# Phrases of (time, pitch) knots; pitch in rows above the band floor. Gaps between phrases are
# breaths. The first two only show on screens wider than 16:9, where more of the past fits.
PHRASES = [
[(-0.60, 22), (-0.56, 19), (-0.52, 25), (-0.47, 31), (-0.43, 29), (-0.38, 24), (-0.34, 18)],
[(-0.29, 17), (-0.25, 21), (-0.21, 27), (-0.16, 24), (-0.12, 30), (-0.07, 28), (0.02, 19)],
[(0.07, 18), (0.11, 21), (0.15, 28), (0.20, 25), (0.24, 33), (0.29, 31), (0.33, 20)],
[(0.37, 17), (0.41, 23), (0.45, 20), (0.49, 28), (0.53, 26)],
[(0.57, 19), (0.61, 26), (0.66, 34), (0.74, 31)], # still sounding when the capture stops
]
# Palette indices: 1 is the noise floor, 2-5 the accent ramp by strength, 6-7 the playhead.
PALETTE = [None, BG_ALT, ACCENT_7, ACCENT_6, ACCENT_4, ACCENT, UI, UI_HI]
# Per harmonic: (core half-width in rows, falloff width in rows, core index, falloff index).
BANDS = [(1.5, 2.5, 5, 4), (1.0, 2.0, 4, 3), (0.8, 1.6, 3, 2)]
# Fraction of a phrase spent swelling and fading, and the vibrato depth in rows.
RISE, FADE, VIBRATO = 0.06, 0.18, 0.25
def melody(t: Field) -> tuple[Field, Field]:
"""The pitch in rows and the loudness in [0, 1] of the tune at times `t`, any shape;
loudness is 0 between phrases."""
f0, amp = np.zeros(t.shape), np.zeros(t.shape)
for knots in PHRASES:
kt, kp = zip(*knots, strict=True)
on = (t >= kt[0]) & (t <= kt[-1])
pitch = CubicSpline(kt, kp, bc_type="clamped")(t) + VIBRATO * np.sin(t * 420)
f0 = np.where(on, pitch, f0)
u = np.clip((t - kt[0]) / (kt[-1] - kt[0]), 0, 1)
amp = np.where(on, np.minimum(1, u / RISE) * np.minimum(1, (1 - u) / FADE) ** 0.7, amp)
return f0, amp
@design(aspects="any")
def draw(s: Canvas) -> None:
# the playhead's top, two thirds across with the future open to its right; a narrower
# screen shows less of the past, and a portrait one holds the panel in its upper third
head = s.pick(landscape=(NOW, 312 / 1080), portrait=(NOW, 0.3), snap=CELL)
cols = round(head.x - MARGIN) // CELL # panel columns; the playhead is the next one
# (SUB, cols) sample times across each column; only the past, up to the playhead, is drawn
x = MARGIN + (np.arange(cols) + (np.arange(SUB)[:, None] + 0.5) / SUB) * CELL
f0, amp = melody(NOW + (x - head.x) / SPAN)
on = amp > 0
track = (np.where(on, f0, np.inf).min(axis=0), np.where(on, f0, -np.inf).max(axis=0))
level = amp[SUB // 2] # loudness at the column centre
f = np.arange(ROWS)[::-1][:, None] + 0.5 # row 0 is the top (high frequency)
thr = np.tile(blue_noise(64, s.np_rng(5)), (ROWS // 64 + 1, cols // 64 + 1))[:ROWS, :cols]
px = Pixels(cols + 4, ROWS + 4, PALETTE) # spare rows above and columns right for the cap
grid = px.grid[4:, :cols]
grid[thr < 0.05] = 1 # noise floor: sparse specks, ~5% coverage
# stroke thickness follows the note envelope; dither only in the thin falloff
live = level > 0.02
for h, (core, fall, ci, fi) in enumerate(BANDS, start=1):
d = np.maximum(np.maximum(h * track[0] - f, f - h * track[1]), 0) - core * level
halo = live & (d > 0) & (thr < np.exp(-((d / fall) ** 2)) * level * 0.7)
grid[halo & (grid < fi)] = fi
body = live & (d <= 0) & (level * core > 0.35)
grid[body] = np.maximum(grid[body], ci)
px.grid[4:, cols] = 6
px.stamp(CAP, cols - 3, 0, {"#": 7})
px.draw(s, CELL, (MARGIN, head.y - 4 * CELL))
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render dither-spectrogram --theme fireproof -o dither-spectrogram-fireproof-16x9.svg