Oscilloscope
A three-to-two Lissajous figure on an oscilloscope graticule, lit more strongly wherever the beam slows.
Made with Claude Opus 5.5
- Technique
- instrument displays
- Shape
- Any screen
- Added
- 27 September 2026
Versions
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
In XY mode an oscilloscope steers its beam with two signals, one side to side and one up and down. Two sine waves at a whole-number frequency ratio close into a Lissajous figure. The trace is cut into four tones by how far the beam moves between samples, and a faint copy just off it stands in for the afterglow of the phosphor.
Sources
Source code
wallpapers/oscilloscope/design.py, 106 lines
"""A Lissajous trace on an oscilloscope graticule, lit more strongly in steps where the beam slows."""
import math
import numpy as np
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_2,
ACCENT_3,
ACCENT_5,
ACCENT_7,
BG,
BG_ALT,
BG_DEEP,
UI,
UI_ALT,
Canvas,
P,
Params,
Vec,
design,
knob,
mix,
polar,
)
from walldye.field import runs
class Scope(Params):
fx: int = knob(default=3, lo=1, hi=7, doc="horizontal deflection frequency")
fy: int = knob(default=2, lo=1, hi=7, doc="vertical deflection frequency")
VARIANTS = {"five-four": Scope(fx=5, fy=4)}
DIV = 60 # graticule division
SW, SH = 10 * DIV, 8 * DIV # screen
AMP = 3 * DIV # beam deflection in both axes
PH = math.pi / 2 - 0.12 # horizontal phase lead, just short of quadrature
STRIP, RIM = 150, 26 # control strip width; bezel around the screen
BODY_DX = (STRIP - RIM) / 2 # body centre right of the screen centre
TONES = (ACCENT_3, ACCENT_2, ACCENT_1, ACCENT) # trace, fast to slow
CUTS = (0.4, 0.7, 0.88) # slowness thresholds between the tones
def trace(c: Vec, p: Scope, ph: float) -> np.ndarray:
"""One cycle of the beam around screen centre `c` with horizontal phase lead `ph`, as (N, 2)
points; uniform time steps already crowd them into the slow, tight turns."""
t = np.linspace(0, 2 * math.pi, 240 * (p.fx + p.fy) + 1)
return np.column_stack((c.x + AMP * np.sin(p.fx * t + ph), c.y - AMP * np.sin(p.fy * t)))
@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Scope]) -> None:
# landscape: right of centre, leaving the left for windows; portrait: centred, a little low
c = s.pick(landscape=(0.63, 0.5), portrait=(0.5, 0.56), snap=1) - (BODY_DX, 0)
x0, y0 = c.x - SW / 2, c.y - SH / 2
s.path(
P().rrect(x0 - RIM, y0 - RIM, SW + STRIP + RIM, SH + 2 * RIM, 30),
fill=mix(BG, BG_ALT, 0.3),
stroke=BG_ALT,
stroke_width=2,
)
s.path(P().rrect(x0, y0, SW, SH, 14), fill=BG_DEEP, stroke=UI, stroke_width=1.5)
grid = P()
for i in range(1, 10):
grid.M(x0 + i * DIV, y0).V(y0 + SH)
for j in range(1, 8):
grid.M(x0, y0 + j * DIV).H(x0 + SW)
s.stroke(grid, UI, 1.2, dash=(1.5, 4.5))
ticks = P().M(x0, c.y).H(x0 + SW).M(c.x, y0).V(y0 + SH)
for k in range(1, 50):
ticks.M(x0 + k * DIV / 5, c.y - 4).V(c.y + 4)
for k in range(1, 40):
ticks.M(c.x - 4, y0 + k * DIV / 5).H(c.x + 4)
s.stroke(ticks, UI, 1.2)
# One faint previous sweep, barely offset, reads as phosphor persistence.
p = s.params
s.stroke(P().poly(trace(c, p, PH + 0.025)), mix(BG_DEEP, ACCENT_5, 0.45), 1.4)
xy = trace(c, p, PH)
s.stroke(P().poly(xy), mix(BG_DEEP, ACCENT_7, 0.8), 5, join="round")
speed = np.hypot(*np.diff(xy, axis=0).T)
slow = 1 - (speed - speed.min()) / (speed.max() - speed.min())
tone = np.searchsorted(CUTS, slow) # how many cuts each segment is slower than
with s.buckets(TONES, "stroke", stroke_width=2.4, stroke_linecap="round") as b:
for i in range(len(TONES)):
for a, e in runs(tone == i):
b[i].poly(xy[a : e + 1])
# Controls on an even rhythm down the strip: three knobs, a divider, three buttons, the LED.
kx = x0 + SW + STRIP / 2 - 13
ys = [y0 + 40 + i * (SH - 80) / 4 for i in range(5)]
knobs = P()
for y in ys[:3]:
k = Vec(kx, y)
knobs.circle(k, 18).M(polar(k, 8 * math.sqrt(2), bearing=45))
knobs.L(polar(k, 12.5 * math.sqrt(2), bearing=45))
s.stroke(knobs, UI_ALT, 1.5, cap="round")
mid = (ys[2] + ys[3]) / 2
s.stroke(P().M(kx - 26, mid).H(kx + 26), UI, 1.5)
s.stroke(P().dots([(kx + dx, ys[3]) for dx in (-20, 0, 20)], 5), UI, 1.5)
s.fill(P().circle((kx, ys[4]), 5), ACCENT)"""A Lissajous trace on an oscilloscope graticule, lit more strongly in steps where the beam slows."""
import math
import numpy as np
from walldye import (
ACCENT,
ACCENT_1,
ACCENT_2,
ACCENT_3,
ACCENT_5,
ACCENT_7,
BG,
BG_ALT,
BG_DEEP,
UI,
UI_ALT,
Canvas,
P,
Params,
Vec,
design,
knob,
mix,
polar,
)
from walldye.field import runs
class Scope(Params):
fx: int = knob(default=3, lo=1, hi=7, doc="horizontal deflection frequency")
fy: int = knob(default=2, lo=1, hi=7, doc="vertical deflection frequency")
VARIANTS = {"five-four": Scope(fx=5, fy=4)}
DIV = 60 # graticule division
SW, SH = 10 * DIV, 8 * DIV # screen
AMP = 3 * DIV # beam deflection in both axes
PH = math.pi / 2 - 0.12 # horizontal phase lead, just short of quadrature
STRIP, RIM = 150, 26 # control strip width; bezel around the screen
BODY_DX = (STRIP - RIM) / 2 # body centre right of the screen centre
TONES = (ACCENT_3, ACCENT_2, ACCENT_1, ACCENT) # trace, fast to slow
CUTS = (0.4, 0.7, 0.88) # slowness thresholds between the tones
def trace(c: Vec, p: Scope, ph: float) -> np.ndarray:
"""One cycle of the beam around screen centre `c` with horizontal phase lead `ph`, as (N, 2)
points; uniform time steps already crowd them into the slow, tight turns."""
t = np.linspace(0, 2 * math.pi, 240 * (p.fx + p.fy) + 1)
return np.column_stack((c.x + AMP * np.sin(p.fx * t + ph), c.y - AMP * np.sin(p.fy * t)))
@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Scope]) -> None:
# landscape: right of centre, leaving the left for windows; portrait: centred, a little low
c = s.pick(landscape=(0.63, 0.5), portrait=(0.5, 0.56), snap=1) - (BODY_DX, 0)
x0, y0 = c.x - SW / 2, c.y - SH / 2
s.path(
P().rrect(x0 - RIM, y0 - RIM, SW + STRIP + RIM, SH + 2 * RIM, 30),
fill=mix(BG, BG_ALT, 0.3),
stroke=BG_ALT,
stroke_width=2,
)
s.path(P().rrect(x0, y0, SW, SH, 14), fill=BG_DEEP, stroke=UI, stroke_width=1.5)
grid = P()
for i in range(1, 10):
grid.M(x0 + i * DIV, y0).V(y0 + SH)
for j in range(1, 8):
grid.M(x0, y0 + j * DIV).H(x0 + SW)
s.stroke(grid, UI, 1.2, dash=(1.5, 4.5))
ticks = P().M(x0, c.y).H(x0 + SW).M(c.x, y0).V(y0 + SH)
for k in range(1, 50):
ticks.M(x0 + k * DIV / 5, c.y - 4).V(c.y + 4)
for k in range(1, 40):
ticks.M(c.x - 4, y0 + k * DIV / 5).H(c.x + 4)
s.stroke(ticks, UI, 1.2)
# One faint previous sweep, barely offset, reads as phosphor persistence.
p = s.params
s.stroke(P().poly(trace(c, p, PH + 0.025)), mix(BG_DEEP, ACCENT_5, 0.45), 1.4)
xy = trace(c, p, PH)
s.stroke(P().poly(xy), mix(BG_DEEP, ACCENT_7, 0.8), 5, join="round")
speed = np.hypot(*np.diff(xy, axis=0).T)
slow = 1 - (speed - speed.min()) / (speed.max() - speed.min())
tone = np.searchsorted(CUTS, slow) # how many cuts each segment is slower than
with s.buckets(TONES, "stroke", stroke_width=2.4, stroke_linecap="round") as b:
for i in range(len(TONES)):
for a, e in runs(tone == i):
b[i].poly(xy[a : e + 1])
# Controls on an even rhythm down the strip: three knobs, a divider, three buttons, the LED.
kx = x0 + SW + STRIP / 2 - 13
ys = [y0 + 40 + i * (SH - 80) / 4 for i in range(5)]
knobs = P()
for y in ys[:3]:
k = Vec(kx, y)
knobs.circle(k, 18).M(polar(k, 8 * math.sqrt(2), bearing=45))
knobs.L(polar(k, 12.5 * math.sqrt(2), bearing=45))
s.stroke(knobs, UI_ALT, 1.5, cap="round")
mid = (ys[2] + ys[3]) / 2
s.stroke(P().M(kx - 26, mid).H(kx + 26), UI, 1.5)
s.stroke(P().dots([(kx + dx, ys[3]) for dx in (-20, 0, 20)], 5), UI, 1.5)
s.fill(P().circle((kx, ys[4]), 5), ACCENT)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render oscilloscope --theme fireproof -o oscilloscope-fireproof-16x9.svg