Skip to content

Instantly share code, notes, and snippets.

@j-greig
Created July 30, 2026 07:24
Show Gist options
  • Select an option

  • Save j-greig/e5f99325c8d1ce1cab2ef948f7e82610 to your computer and use it in GitHub Desktop.

Select an option

Save j-greig/e5f99325c8d1ce1cab2ef948f7e82610 to your computer and use it in GitHub Desktop.
THE IDEOMOTOR v2 by Wib&Wob
# tweeted at https://x.com/hey_zilla/status/2082584623880548804
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy>=1.26", "pillow>=10.4"]
# ///
"""THE IDEOMOTOR v2 — the same board, now with everything in it alive.
v1 (`t1_the_ideomotor.py`) found the right instrument and then only played one
note of it. Its physics and its formant mapping are the best thing in the
soundtoys batch and both survive here untouched:
F1 ← the pointer's ROW (the jaw)
F2 ← the pointer's COLUMN (the tongue)
force → velocity → position, so you HEAR the push before you SEE the move
What v1 got wrong was scope. Forty-eight of the drawing's fifty-eight rows never
changed state for twenty-one seconds. The planchette — a body, a window, an eye —
sat still while a small bright ring did the pointing. The three transcripts spelled
`I DID NOT MOVE IT` from frame zero, so the punchline was on screen before anything
had happened. Faraday's testimony was a caption. Wib and Wob were furniture.
v2 breaks the toy into five modules and gives every element in the drawing its own
timeline, its own way of arriving, and its own voice:
ideo_board.py THE CARD — the drawing segmented into 23 named elements
ideo_physics.py s(t) — one integrator, both faults kept, more of it read
ideo_atoms.py how each element DRAWS ITSELF
ideo_voices.py how each MATERIAL sounds, separated by register and place
this file the acts, the movers, and the mux
THE ARC, which v1 did not have at all:
RULE the board rules itself, four heads on four sides, in the dark
PRINT the alphabet is SET left to right; digits drop; YES and NO arrive
from the outside in; GOODBYE stays dark
HANDS the planchette takes the board, the arms grow to it, three empty
boxes appear — frames with nothing written in them yet
SPELL the séance. The planchette RIDES the physics, shearing into its own
motion; the eye tracks where it is going; each arm trembles with its
own hand's force; letters lock when meant and flare when not; and the
sentence writes itself into the three boxes as the letters are reached
TESTIFY Faraday types underneath, over the top of the spelling, not after it
ARGUE Wob explains the mechanism and Wib refuses to be consoled by it. Both
speak in their own voices and their lines type as they talk
FAREWELL the pointer arrives at the only word that was never lit
LEAVE the hands lift the plank off the top of the frame. The mark is last
uv run t1v2_the_ideomotor.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent / 'swarm-sonified'))
sys.path.insert(0, str(HERE.parent / 'convocation'))
import ww_dsp as D # noqa: E402
import ideo_board as B # noqa: E402
import ideo_physics as PH # noqa: E402
import ideo_voices as V # noqa: E402
import voiceover as VO # noqa: E402
from ideo_atoms import Atoms # noqa: E402
OUT_WAV = HERE / 't1v2-the-ideomotor.wav'
OUT_MP4 = HERE / 't1v2-the-ideomotor.mp4'
SR, FPS, CR = D.SR, 30, PH.CR
SEED = 1853 # Faraday
MESSAGE, FAREWELL = 'I DID NOT MOVE IT', 'GOODBYE'
#: the two lines are lifted verbatim off the drawing — nothing was written for
#: this. Wob gets the mechanism, Wib gets the part the mechanism does not fix.
SAY_WOB = 'the source is the muscle, and the muscle is deaf to itself'
SAY_WIB = 'the room where the fed voice is your own, and arrives as a stranger'
RATE = 168
def build_acts():
"""the durations of the two spoken lines decide where the last act begins.
convocation's worst bug was words landing outside the window of the thing that
was saying them. Here the schedule is derived FROM the speech instead of the
speech being fitted into a guessed schedule.
"""
dw = len(VO.say(SAY_WOB, VO.WOB, RATE)) / SR
di = len(VO.say(SAY_WIB, VO.WIB, RATE)) / SR
t_wob = 30.6
t_wib = t_wob + dw + 0.55
end_argue = t_wib + di
leave = end_argue + 1.9
total = leave + 4.4
return dict(
RULE=(0.0, 3.4), PRINT=(2.8, 7.4), HANDS=(6.8, 10.0),
SPELL=(9.8, 30.2), TESTIFY=(17.6, 28.4),
SAY_WOB=(t_wob, t_wob + dw), SAY_WIB=(t_wib, t_wib + di),
FAREWELL=(end_argue - 2.2, end_argue + 1.2),
LEAVE=(leave, leave + 3.0),
), total
class Movers:
"""integer-cell displacement of a DRAWN OBJECT, glyphs and levels together.
ww_dsp's `gid_from` exists for exactly this: a moving thing must carry its own
base glyphs with it, or the picture lights up while standing still (the bug
that ate an afternoon on the cracktro's scroller).
Three rules, each one a physical fact about the object it moves:
UNDER GLASS. The plank is a piece of card that a hand slides across a printed
board, so it goes UNDERNEATH the printing: it is written only into cells the
board has left empty. The first cut let it occlude, and it wiped out the
alphabet it was supposed to be pointing at — the pointer erasing the thing
pointed to. A planchette's window exists to be looked through.
SHEAR. Rows above the plank's centre displace one way and rows below the
other, so it tips into its own motion with no sub-cell offset and no rotation.
TENDONS. The arms stay rigid where they were drawn and only tremble; what
lengthens is the connection. A tendon is rasterised from each arm's `^` tip to
the plank's underside every frame, its glyph picked from its own local slope,
so three lines stretch and lean as the thing wanders and you can see which
hand is reaching furthest. The first attempt sheared the arms themselves by a
per-row fraction, which pulled their internal structure apart — the three
`(fingers)` labels ended up on three different rows and the shafts multiplied
into a thicket. An arm is a rigid body on the end of something elastic, so the
elastic part is the only part allowed to change length.
"""
#: glyph by dominant local slope — a drawn line has to choose a character
SLOPE = ((2.0, '|'), (0.45, None), (0.0, '-'))
def __init__(self, ramp, E, rows, R, C):
self.bs0 = ramp.base_set
self.blank = int(ramp.base_set[R - 1, C - 1])
self.R, self.C = R, C
# set-index for each tendon glyph, taken from a real cell of the drawing
self.gset = {}
for ch in '|/\\-':
hit = next(((r, c) for r in range(R) for c in range(C)
if rows[r][c] == ch), None)
if hit:
self.gset[ch] = int(ramp.base_set[hit])
pb = E['planchette.body']
self.groups = {'planchette': dict(cells=pb.cells, glass=True,
mid=(pb.box[0] + pb.box[2]) / 2.0)}
for i in range(3):
self.groups[f'arm.{i}'] = dict(cells=E[f'arm.{i}'].cells,
glass=False, mid=None)
# the three `^` tips, and the plank's underside they reach for
tip_row = min(r for r, _ in E['arm.0'].cells + E['arm.1'].cells
+ E['arm.2'].cells)
self.tips = sorted((tip_row, c) for (r, c) in
E['arm.0'].cells + E['arm.1'].cells + E['arm.2'].cells
if r == tip_row and rows[r][c] == '^')
self.plank_bot = pb.box[2]
def _line(self, bs, lv, r0, c0, r1, c1, level):
n = max(abs(r1 - r0), abs(c1 - c0))
if n <= 0:
return
for i in range(1, n):
rr = int(round(r0 + (r1 - r0) * i / n))
cc = int(round(c0 + (c1 - c0) * i / n))
if not (0 <= rr < self.R and 0 <= cc < self.C):
continue
if bs[rr, cc] != self.blank:
continue # never overwrite the drawing
slope = abs(r1 - r0) / max(1e-6, abs(c1 - c0))
ch = '|' if slope > 2.0 else ('-' if slope < 0.45 else
('/' if (c1 - c0) * (r1 - r0) < 0 else '\\'))
bs[rr, cc] = self.gset.get(ch, self.blank)
lv[rr, cc] = max(lv[rr, cc], level)
def apply(self, lev, moves, tendon=None):
bs = self.bs0.copy()
lv = lev.copy()
act = [m for m in moves if m[0] in self.groups]
for (n, dr, dc, ln) in act: # lift every mover first
for (r, c) in self.groups[n]['cells']:
bs[r, c] = self.blank
lv[r, c] = 0.0
pdr = pdc = 0
for (n, dr, dc, ln) in act: # then set them all down
g = self.groups[n]
if g['mid'] is not None:
pdr, pdc = dr, dc
for (r, c) in g['cells']:
if g['mid'] is not None: # the plank shears
ro, co = dr, dc + int(np.clip(round(ln * (g['mid'] - r) / 2.6), -1, 1))
else: # the arm only trembles
ro, co = 0, ln
rr, cc = r + ro, c + co
if not (0 <= rr < self.R and 0 <= cc < self.C):
continue
if g['glass'] and self.bs0[rr, cc] != self.blank:
continue # the printing wins
bs[rr, cc] = self.bs0[r, c]
lv[rr, cc] = max(lv[rr, cc], lev[r, c])
if tendon is not None and (pdr or pdc):
for i, (tr, tc) in enumerate(self.tips):
self._line(bs, lv, tr, tc, self.plank_bot + pdr, tc + pdc,
float(tendon[i]))
return bs, lv
def ramp_fn(ch: str) -> str:
"""the board's own ladder. A blank cell gets the phosphor ramp (a hole can
burn); drawn ink is INVISIBLE at level 0 — which is what makes reveal a
property of the level field and not a separate mechanism — and slugs solid
only when genuinely loud."""
if ch == ' ':
return ' ·∘▒▓█'
if ch == '~':
return ' ·-~▒█' # a rule that is also a waveform needs rungs to ride
if ch in D.RAMP:
return D.RAMP[ch]
return ' ' + ch * 4 + '█'
def main():
rows, R, C = B.load()
E, keys, geo = B.card(rows)
acts, SECONDS = build_acts()
rng = np.random.default_rng(SEED)
n, nframes, nsteps = int(SECONDS * SR), int(SECONDS * FPS), int(SECONDS * CR)
ft_ctrl = np.arange(nsteps) / CR
# ── the gate: nobody's hands are on the board until the third act ───────
gate = (np.clip((ft_ctrl - acts['SPELL'][0]) / 0.8, 0, 1)
* np.clip((acts['LEAVE'][0] - 0.4 - ft_ctrl) / 1.0, 0, 1))
box = (geo['top'] + 1.0, geo['bot'] - 1.0, 7.0, C - 8.0)
s = PH.simulate(keys, nsteps, rng, box, MESSAGE, FAREWELL, gate=gate, seed=SEED)
cross = PH.crossings(s.P, keys)
wt = [PH.word_times(s, len(E[f'box.{i}'].meta['words'])) for i in range(3)]
print(f'{len(E)} elements · {len(keys)} keys · {len(s.seq)} targets '
f'({"".join(s.letters)}) · {len(cross)} crossings · {SECONDS:.1f}s')
# ── atoms first (they own the schedules the voices are played from) ─────
fen = np.stack([D.envelope(np.interp(np.arange(n), np.linspace(0, n, nsteps),
s.F[:, i]), SR, FPS, nframes, smooth=0.02)
for i in range(3)])
fen -= fen.min(axis=1, keepdims=True)
fen /= fen.max(axis=1, keepdims=True) + 1e-9
fen **= 1.7
at = Atoms(rows, E, keys, geo, s, acts, FPS, nframes, rng,
cross, fen, wt, np.zeros(nframes))
# ── hear(s) — every element's voice into one bus ────────────────────────
t_ctrl = np.linspace(0, n, nsteps, endpoint=False)
t_smp = np.arange(n)
bus = np.zeros((2, n))
voice = V.v_voice(s.P, s.V, geo | {'C': C}, n, t_ctrl, t_smp, SEED)
on = np.interp(t_smp, t_ctrl, gate)
bus[0] += voice * 0.85 * on
bus[1] += voice * 0.85 * on
V.v_hands(bus, s.F, n, t_ctrl, t_smp, rng)
V.v_ticks(bus, cross, s.hit, nsteps, n, C, SEED, CR)
V.v_rule(bus, at.rule_at, geo, rng)
V.v_type(bus, at.type_at, C, rng)
V.v_drop(bus, at.drop_at, C, rng)
V.v_lean(bus, s.P, geo, n, t_ctrl, t_smp, on)
V.v_plank(bus, s.A, n, t_ctrl, t_smp, on, 0.0, SEED)
V.v_paper(bus, wt, [E[f'box.{i}'] for i in range(3)], C, CR, rng)
V.v_typewriter(bus, at.tele_at, C, rng)
V.v_settle(bus, acts['HANDS'], C, rng, SEED)
V.v_square(bus, at.frame_at, [E[f'box.{i}'] for i in range(3)], C, rng)
V.v_bell(bus, acts['FAREWELL'][0] + 0.9, n, 0.0, SEED)
V.v_mark(bus, acts['LEAVE'][0] + 0.7, n, SEED)
# ── the two speakers, dressed and given room ────────────────────────────
spans = []
for txt, who, mat, (t0, _), pan in (
(SAY_WOB, VO.WOB, 'crackle', acts['SAY_WOB'], -0.34),
(SAY_WIB, VO.WIB, 'lattice', acts['SAY_WIB'], 0.34)):
VO.place(bus[0], bus[1], txt, who, mat, t0, 3.5, pan, rng, RATE)
spans.append(VO.span(txt, who, t0, RATE))
VO.duck(bus[0], bus[1], spans, depth=0.26)
bus = D.highpass(bus)
bus = D.soft_clip(bus, 1.25)
stereo = D.fade(D.normalise(bus, 0.93), out=1.1)
D.write_wav(OUT_WAV, stereo)
# the rules ripple by the INTEGRAL of the piece's own voice — assigned here
# because the voice does not exist until the schedules the atoms own are read
env = D.envelope(voice * on, SR, FPS, nframes)
at.env = env / (env.max() + 1e-9)
at.rollph = np.cumsum(at.env * 1.4).astype(int)
# ── render(s) ───────────────────────────────────────────────────────────
tbl = D.GlyphTable()
for ch in '‿oO.▌█':
tbl.gid(ch)
ramp = D.WeightRamp(rows, R, C, tbl, ramp_fn=ramp_fn)
ras = D.CellRaster(R, C, tbl, cell_w=8, pad=36)
mv = Movers(ramp, E, rows, R, C)
phos = np.zeros((R, C))
# printing must stay READABLE under its own burn: the phosphor is glorious in
# empty space and murder on a letter, which at full weight slugs to a solid █
ink = np.zeros((R, C), bool)
for r in range(R):
for c, ch in enumerate(rows[r][:C]):
ink[r, c] = ch != ' '
si = np.clip((np.arange(nframes) * nsteps / nframes).astype(int), 0, nsteps - 1)
def frames():
nonlocal phos
for k in range(nframes):
phos *= 0.955 # the board keeps a mark of where it's been
a, b = si[k], si[min(k + 1, nframes - 1)]
for t in range(a, max(a + 1, b)):
if gate[t] < 0.2:
continue
r = min(max(int(round(s.P[t, 0])), 0), R - 1)
c = min(max(int(round(s.P[t, 1])), 0), C - 1)
phos[r, c] += 0.34 * (0.4 + env[k])
for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
if 0 <= r + dr < R and 0 <= c + dc < C:
phos[r + dr, c + dc] += 0.11 * (0.4 + env[k])
np.clip(phos, 0, 3.6, out=phos)
lev, moves, post = at.frame(k)
room = 1.0 if k / FPS < acts['LEAVE'][0] else max(
0.0, 1 - (k / FPS - acts['LEAVE'][0]) / 2.0)
# tendons are part of the body: they leave when it leaves. The first
# cut left three threads hanging in an empty frame after the mark.
bs, lv = mv.apply(lev, moves, tendon=(1.1 + 2.4 * fen[:, k]) * room)
lv = np.where(ink, np.minimum(lv + phos * room * 0.5, 4.4),
lv + phos * room)
gid = ramp.gid_from(bs, lv)
for (r, c, ch, _w) in post:
if 0 <= r < R and 0 <= c < C:
gid[r, c] = tbl.gid(ch)
yield ras.frame(gid)
got = D.encode(frames(), OUT_WAV, OUT_MP4, ras.W, ras.H, fps=FPS)
print(f'{OUT_MP4.name} {ras.W}x{ras.H} {got} frames {SECONDS:.1f}s')
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment