Oscilloscope
An oscilloscope in XY mode turns two sine waves in a whole-number ratio into a closed Lissajous figure.
Made with Claude Opus 5.5
- Technique
- instrument displays
- Shape
- Any screen
- Added
- 27 September 2026
Versions
Export
Colors
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Notes
In XY mode the beam moves sideways with one signal and up and down with the other. The default ratio is three to two and the other version’s is five to four. The trace is cut into four tones by how far the beam moves between samples, and a second copy just off it stands in for the afterglow of the screen’s phosphor.
Sources
Source code
wallpapers/
"""A Lissajous figure on an oscilloscope screen."""
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,
Rect,
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 center right of the screen center
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 center `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 center, leaving the left for windows; portrait: centered, a little low
body = s.pick(landscape=(0.63, 0.5), portrait=(0.5, 0.56), snap=1)
c = Vec(-BODY_DX, 0) # the screen's center, in motif units around the body's
p = s.params
# the body, and the 1 unit its outline's stroke reaches past it
w, h = SW + STRIP + RIM + 2, SH + 2 * RIM + 2
with s.motif(body, Rect(-w / 2, -h / 2, w, h)):
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.
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 figure on an oscilloscope screen."""
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,
Rect,
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 center right of the screen center
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 center `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 center, leaving the left for windows; portrait: centered, a little low
body = s.pick(landscape=(0.63, 0.5), portrait=(0.5, 0.56), snap=1)
c = Vec(-BODY_DX, 0) # the screen's center, in motif units around the body's
p = s.params
# the body, and the 1 unit its outline's stroke reaches past it
w, h = SW + STRIP + RIM + 2, SH + 2 * RIM + 2
with s.motif(body, Rect(-w / 2, -h / 2, w, h)):
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.
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