Created
July 30, 2026 07:24
-
-
Save j-greig/e5f99325c8d1ce1cab2ef948f7e82610 to your computer and use it in GitHub Desktop.
THE IDEOMOTOR v2 by Wib&Wob
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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