#!/usr/bin/env python3 """Lifelike face motion for the Shining LED mask — SanadV3. A richer, more *organic* driver than the Mask lib's built-in idle. Instead of an occasional blink/glance, it gives the robot's face the small, constant motion a real face has: * natural blinking — varied intervals, occasional quick double-blinks * frequent small eye saccades (darts) with short gaze holds and drift * idle micro-expressions (a brief smile now and then) * state-aware behaviour: idle — relaxed, wanders, blinks listening — attentive, eyes mostly forward, fewer darts, soft blinks thinking — looks away (longer gaze holds), slower blinks speaking — mouth lip-syncs to audio + the odd mid-sentence blink * quick reactions: surprised / happy(smile) / sad, held briefly then released It drives the mask by PLAY of the pre-uploaded DIY frames (no per-frame upload), so motion is smooth. Lip-sync composes with the eye motion via feed_audio_level(). Run it standalone (keep the mask within ~30 cm for the one-time frame upload): python3 face/face_motion.py # connect, load frames, stay alive python3 face/face_motion.py --demo # cycle the states to show the range python3 face/face_motion.py --reload # force re-upload of the frame set Integrate into Sanad: construct ``LifelikeFace(mask=)`` (or let it connect itself), ``await face.start()``, then drive it from the event bus / Gemini lip-sync markers: face.set_listening() # when the user starts speaking face.set_thinking() # while a tool/response is being prepared face.set_speaking(True/False) # around a spoken reply face.feed_audio_level(rms_0_to_1) # per audio chunk -> real lip-sync face.react("surprised" | "smile" | "sad") face.set_idle() # back to relaxed wandering """ from __future__ import annotations import argparse import asyncio import os import random import sys import time import logging import threading from pathlib import Path log = logging.getLogger("sanad.face_motion") # Frames present in the mask's DIY slots (colorface.default_frames()). GAZE = ("neutral", "look_left", "look_right") MOUTH = ("neutral", "talk1", "talk2", "talk3") # How long after the last lip-sync marker the face stays "speaking" (mouth # follows the amplitude; pauses close it). When markers stop for this long the # turn ends and the eyes return to their underlying state. _SPEECH_WINDOW = 0.6 # Mouth-frame cadence while speaking. Each frame is a FULL-face DIY slot switch; # this small mask can't cleanly repaint the LED matrix faster than ~5/s, so the # old 0.09s (~11/s) cadence overran it and showed torn/scrambled composites of # several frames at once. Cap it well under the tear threshold — speech visemes # only change ~4-7/s anyway, so lip-sync still reads fine. _SPEAK_FRAME_SEC = 0.22 # BLE-link health. If the mask link drops mid-session every play_diy raises a # BleakError, which left the loop busy-spinning ~20x/s forever (no recovery but a # manual disconnect/connect). Treat a run of consecutive play failures (or # mask.is_connected going False) as a dropped link and attempt a *bounded* # reconnect; if that is exhausted, stop the loop so the face goes idle/unavailable # instead of hammering a dead transport. _PLAY_FAIL_LIMIT = 10 # consecutive failed plays before we call it a drop _RECONNECT_ATTEMPTS = 3 # reconnect tries per detected drop _RECONNECT_BACKOFF = 2.0 # seconds between reconnect tries # BLE link keepalive. _play() skips re-sending an *unchanged* frame, so a long # neutral/idle stretch writes nothing but the occasional blink (every 2-4.5s). # If that quiet gap (plus an RF glitch) outlasts the link's supervision timeout # the mask drops — and every reconnect briefly flashes the mask's own built-in # face. Re-send the current frame at least this often so the link never goes # quiet long enough to be dropped. One tiny write/sec when idle; free when busy. _KEEPALIVE_SEC = 1.0 # Frames that carry the animated face's mouth (gaze + lip-sync). "Hide mouth" # blacks out the mouth region on just these, leaving eyes-only — the emotion # icons (heart/thumb/…) are left alone. _MOUTH_FRAMES = ("neutral", "talk1", "talk2", "talk3", "blink", "look_left", "look_right") _MOUTH_MASK_TOP = 32 # display-y below which the mouth lives (eyes end ~29) def _mask_mouth_bytes(data: bytes) -> bytes: """Return a copy of an encoded 46x58 frame with the mouth region blacked out (decode the transposed bytes -> mask display rows >= _MOUTH_MASK_TOP -> re-encode).""" import colorface as _cf from PIL import Image as _Image, ImageDraw as _ImageDraw img = _Image.frombytes("RGB", (_cf.DISPLAY_H, _cf.DISPLAY_W), bytes(data)) img = img.transpose(_Image.Transpose.TRANSPOSE) # -> 46x58 display space _ImageDraw.Draw(img).rectangle([0, _MOUTH_MASK_TOP, _cf.DISPLAY_W, _cf.DISPLAY_H], fill=(0, 0, 0)) return _cf.encode(img) def _add_mask_to_path() -> str: """Put the flat Mask library (mask.py / faceanim.py / colorface.py) on sys.path.""" d = os.environ.get("SANAD_MASK_DIR") or str(Path(__file__).resolve().parents[2] / "Mask") if d and d not in sys.path: sys.path.insert(0, d) return d class LifelikeFace: """Organic, state-aware motion driver for the LED mask.""" def __init__(self, mask=None, *, name_prefix="MASK", address=None, adapter=None, brightness=95, frames=None, eye_color=None, mouth_color=None, sclera_color=None, auto_reconnect=True, hide_mouth=False): _add_mask_to_path() import mask as _mask # flat Mask lib import faceanim as _faceanim import colorface as _colorface self._ShiningMask = _mask.ShiningMask self._FaceAnimator = _faceanim.FaceAnimator self._colorface = _colorface self.mask = mask self._own_mask = mask is None self.name_prefix = name_prefix self.address = address self.adapter = adapter self.brightness = int(brightness) # When False, a dropped link is NOT self-healed here — the loop bails # cleanly (and forces the transport disconnected) so an external owner # (FaceController's reconnect supervisor) brings the link + face back. self._auto_reconnect = bool(auto_reconnect) # Frame colors: explicit frames win; else build the default set tinted # with whatever colors were given (None -> the lib defaults cyan/red). if frames is None: ck = {} if eye_color is not None: ck["eye_color"] = tuple(eye_color) if mouth_color is not None: ck["mouth_color"] = tuple(mouth_color) if sclera_color is not None: ck["sclera_color"] = tuple(sclera_color) frames = _colorface.default_frames(**ck) # Extra Gemini-triggerable emotions (heart, laugh, love-eyes, cool, # sleepy, confused, kiss, star_struck) in the same style. Appended # after the base set so slot ids 1..N stay stable for existing # frames. Guarded: a missing module never breaks the face. try: from Project.Sanad.face.emotion_frames import emotion_frames as _emo # 7 emotions so slots 1..19 hold the face set and slot 20 stays # free as a scratch slot for QR/social images (mask caps at 20). frames = {**frames, **_emo(**ck, include={ "heart", "laugh", "love", "cool", "confused", "kiss", "thumbs_up"})} except Exception: log.exception("emotion frames unavailable — base frames only") self.frames = frames # Reserved DIY slot (just past the animated frames) for on-demand images # (QR / social) shown via the FaceController's show_scratch_image(). self.scratch_slot = len(self.frames) + 1 # Mouth show/hide: keep the unmasked originals so a live toggle can # re-upload just the gaze/talk slots masked or normal. self._base_frames = dict(self.frames) self.hide_mouth = bool(hide_mouth) if self.hide_mouth: self.frames = {n: (_mask_mouth_bytes(d) if n in _MOUTH_FRAMES else d) for n, d in self.frames.items()} def mouth_frames_for(self, hidden: bool) -> dict: """{name: bytes} for the gaze/talk frames, masked (hidden) or normal — the FaceController re-uploads just these slots to toggle the mouth live.""" return {n: (_mask_mouth_bytes(self._base_frames[n]) if hidden else self._base_frames[n]) for n in _MOUTH_FRAMES if n in self._base_frames} self.slots: dict = {} self._state = "idle" # underlying eye state: idle|listening|thinking self._speaking = False # explicit speaking turn (set_speaking) self._level = 0.0 # live lip-sync amplitude 0..1 self._last_mouth_t = 0.0 # last set_mouth/feed_audio_level time self._react = None self._react_until = 0.0 self._cur = None self._task = None self._stop = False self._play_fails = 0 # consecutive play_diy failures (link-drop signal) self._last_write = 0.0 # monotonic of the last successful play_diy (keepalive) self._paused = False # loop stops writing (used during a scratch upload) self._paused_ack = threading.Event() # set once the loop has actually parked # -- lifecycle ------------------------------------------------------------ async def start(self, *, reload: bool = False): if self.mask is None: self.mask = self._ShiningMask( address=self.address, name_prefix=self.name_prefix, adapter=self.adapter) await self.mask.connect(timeout=20.0, attempts=12) await self.mask.set_brightness(self.brightness) # Upload the frame set via the RELIABLE (acked) image path — see # _upload_frames. We no longer borrow FaceAnimator.load(), whose # fire-and-forget upload silently corrupts slots on a marginal link (a # dropped packet -> garbage frame, no exception -> no retry). await self._upload_frames(force=reload) self._stop = False await self._play("neutral") self._task = asyncio.create_task(self._loop()) return self async def stop(self): self._stop = True if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None if self._own_mask and self.mask is not None: try: await self.mask.disconnect() except Exception: pass async def __aenter__(self): return await self.start() async def __aexit__(self, *exc): await self.stop() # -- reliable frame upload ------------------------------------------------ async def _upload_frames(self, *, force: bool): """Upload the frame set to the mask's DIY slots, RELIABLY. The mask's default DIY upload is fire-and-forget: ~80 write-without- response packets per frame with no ack, so a packet dropped on a marginal BLE link silently corrupts that slot (no exception -> no retry) and the frame renders as garbage. We instead drive each frame through the mask's ACKED image path (upload_image -> _upload(kind=IMAGE), per-packet REOK), retrying the whole frame on any failure (a fresh DATS resets the half-written slot). Same name->slot (1..N) map as before. """ names = list(self.frames) self.slots = {name: i + 1 for i, name in enumerate(names)} if not force: try: count = await self.mask.get_diy_count(timeout=4.0) or 0 except Exception: count = 0 if count >= len(names): return # frames already stored (persist in flash) await self.mask.clear_diy() acked = True for i, (name, data) in enumerate(self.frames.items(), start=1): acked = await self._upload_one_frame(i, bytes(data), acked) await asyncio.sleep(0.2) async def _upload_one_frame(self, slot: int, data: bytes, acked: bool) -> bool: """Upload one frame. Prefer the acked image path; on failure reconnect + retry the whole frame. If frame 1 proves this mask clone never acks IMAGE uploads, latch off the acked path and use paced fire-and-forget for the rest. Returns whether to keep using the acked path.""" if acked: for attempt in range(5): try: await self.mask.upload_image(data, slot, timeout=8.0) return True except Exception as exc: # frame 1 failing its first two acked tries => this clone # doesn't ack IMAGE uploads; stop trying it. if slot == 1 and attempt >= 1: log.warning("mask: IMAGE uploads not acked by this clone " "(%s) -- using paced fire-and-forget", exc) break if attempt == 4: log.warning("mask: acked upload of slot %d exhausted (%s) " "-- fire-and-forget fallback", slot, exc) break await self._reupload_reconnect() # fallback: paced fire-and-forget (probabilistic -- keep the mask close) await self.mask.upload_raw_image(data, index=slot, chunk_delay=0.10, init_delay=0.30) return False async def _reupload_reconnect(self): """Drop + re-establish the link mid-upload so the next frame attempt starts clean (a fresh DATS resets any half-written slot).""" try: await self.mask.disconnect() except Exception: pass await asyncio.sleep(1.0) try: await self.mask.connect(timeout=15.0, attempts=8) await self.mask.set_brightness(self.brightness) except Exception: pass # -- control -------------------------------------------------------------- def set_state(self, state: str): self._state = state if state in ("idle", "listening", "thinking", "speaking") else "idle" def set_idle(self): self._speaking = False self._last_mouth_t = 0.0 # leave any speaking overlay immediately self.set_state("idle") def set_listening(self): self._speaking = False self._last_mouth_t = 0.0 self.set_state("listening") def set_thinking(self): self._speaking = False self._last_mouth_t = 0.0 self.set_state("thinking") def set_speaking(self, on: bool): """Mark a speaking turn. Without lip-sync markers the mouth auto-talks; with them it follows the amplitude. The underlying eye state is kept, so it returns there when the turn ends.""" self._speaking = bool(on) if not on: self._last_mouth_t = 0.0 self._level = 0.0 def feed_audio_level(self, level: float): """Per-audio-chunk amplitude 0..1 -> real lip-sync (mouth opens by loudness). Keeps the face 'speaking' for a short window after the last call, so pauses close the mouth and the turn ends cleanly when markers stop — without depending on an explicit speaking on/off signal.""" self._level = max(self._level * 0.4, min(1.0, float(level))) # fast attack self._last_mouth_t = time.monotonic() def react(self, emotion: str, hold: float = 1.4): """Briefly hold an expression (surprised / smile / sad / …) then release.""" if emotion in self.slots: self._react = emotion self._react_until = time.monotonic() + float(hold) # -- FaceController-compatible API (so it can drop in for FaceAnimator) ---- def set_mouth(self, level: int): """Discrete mouth level 0..3 (e.g. from the Gemini [[MOUTH:n]] relay) -> drives lip-sync. Maps the level to a representative amplitude; level 0 decays the mouth shut but keeps the short speaking window alive.""" amp = (0.0, 0.12, 0.24, 0.5)[max(0, min(3, int(level)))] self._level = max(self._level * 0.4, amp) self._last_mouth_t = time.monotonic() def set_expression(self, name): """Hold an expression frame until cleared with None (vs the timed react).""" if name and name in self.slots: self._react = name self._react_until = float("inf") elif self._react_until == float("inf"): self._react = None def pause(self): """Stop the loop from writing to the mask (so a concurrent scratch-slot upload's per-packet acks aren't disturbed by play_diy traffic).""" self._paused_ack.clear() self._paused = True def wait_paused(self, timeout: float = 2.0) -> bool: """Block until the loop has actually reached the paused branch (so no play_diy is in flight when the caller starts the scratch upload).""" return self._paused_ack.wait(timeout) def resume(self): self._paused = False self._paused_ack.clear() self._cur = None # force a redraw when the loop takes over again async def show(self, name: str): """One-off: briefly show a named frame (used by FaceController.show_expression).""" if name in self.slots: self.react(name, hold=1.5) # -- internals ------------------------------------------------------------ @staticmethod def _mouth_for(level: float) -> str: i = 0 if level < 0.06 else 1 if level < 0.16 else 2 if level < 0.32 else 3 return MOUTH[i] async def _play(self, name: str, *, force: bool = False): slot = self.slots.get(name) if slot is None or (name == self._cur and not force): return try: await self.mask.play_diy(slot) self._cur = name self._last_write = time.monotonic() # keepalive clock: link saw traffic self._play_fails = 0 # link is alive again except Exception: self._cur = None # retry next tick on a transient BLE error self._play_fails += 1 # ...but count it: a sustained run == a drop def _link_dead(self) -> bool: """True once the BLE link looks gone: the transport reports disconnected, or play_diy has failed a sustained run in a row (a single glitch is still treated as transient and retried).""" connected = bool(getattr(self.mask, "is_connected", False)) if self.mask else False return (not connected) or self._play_fails >= _PLAY_FAIL_LIMIT async def _reconnect(self) -> bool: """Bounded reconnect after a detected drop. Frames persist on the mask's flash, so on success we only re-pin brightness + redraw the current frame (no re-upload). Returns True if the link is back, False if exhausted.""" for i in range(_RECONNECT_ATTEMPTS): if self._stop: return False try: if getattr(self.mask, "is_connected", False): await self.mask.disconnect() # clean any half-open client first except Exception: pass try: await self.mask.connect(timeout=10.0, attempts=2) await self.mask.set_brightness(self.brightness) self._play_fails = 0 self._cur = None # force a redraw on the fresh link await self._play("neutral") return True except Exception: await asyncio.sleep(_RECONNECT_BACKOFF) return False async def _blink(self, restore: str): await self._play("blink") await asyncio.sleep(random.uniform(0.08, 0.13)) if random.random() < 0.18: # occasional quick double-blink await self._play(restore) await asyncio.sleep(random.uniform(0.07, 0.11)) await self._play("blink") await asyncio.sleep(random.uniform(0.08, 0.12)) await self._play(restore) async def _loop(self): mono = time.monotonic t_blink = mono() + random.uniform(1.5, 4.0) t_sacc = mono() + random.uniform(0.6, 1.6) t_micro = mono() + random.uniform(12.0, 25.0) gaze = "neutral" while not self._stop: t = mono() # BLE link health: if it dropped, try a bounded reconnect instead of # busy-spinning play_diy on a dead transport. If reconnect is # exhausted, leave the loop so the face stops (the controller's # status() then reports it not running) rather than spinning forever. if self._link_dead(): if not self._auto_reconnect: # Owner-managed recovery (FaceController supervisor): make the # transport report disconnected so the supervisor's is_connected # check fires, then leave the loop. The supervisor reconnects # and rebuilds the face (frames persist on the mask's flash). try: if getattr(self.mask, "is_connected", False): await self.mask.disconnect() except Exception: pass break if not await self._reconnect(): break t = mono() # reconnect can take a while # Paused (during a scratch-slot upload): write nothing so the upload's # per-packet REOK acks aren't disturbed by play_diy traffic. Signal # that we've actually parked so the caller can start the upload. if self._paused: self._paused_ack.set() await asyncio.sleep(0.1) continue # BLE keepalive: re-send the current frame if the link has gone quiet. # _play() skips unchanged frames, so a long neutral idle stretch writes # nothing but blinks; a quiet gap past the supervision timeout drops the # link, and each reconnect flashes the mask's built-in face. A cheap # periodic re-send keeps the link alive (no-op while speaking — that # path already writes ~11x/s, so _last_write stays fresh). if self._cur is not None and (t - self._last_write) >= _KEEPALIVE_SEC: await self._play(self._cur, force=True) # transient reaction overrides everything briefly if self._react is not None: if t < self._react_until: await self._play(self._react) await asyncio.sleep(0.06) continue self._react = None self._cur = None # force a redraw of whatever's underneath # "speaking" = an explicit turn OR fresh lip-sync markers (the latter # window auto-expires, so the mouth closes and the turn ends when the # markers stop, without needing a reliable speaking-off signal). lipsync_active = (t - self._last_mouth_t) < _SPEECH_WINDOW if self._speaking or lipsync_active: if lipsync_active: base = self._mouth_for(self._level) # 0 = closed on pauses self._level *= 0.55 # decay toward closed else: base = MOUTH[random.choice([0, 1, 1, 2, 2, 3, 3, 2, 1, 0])] # auto-talk await self._play(base) # No mid-speech blink: a blink is a 2-3 frame burst that, on top # of the mouth cadence, spikes the switch rate and tears the # display. Eyes blink between utterances (idle/listening) instead. await asyncio.sleep(_SPEAK_FRAME_SEC) continue # --- non-speaking: idle / listening / thinking --- if t >= t_blink: await self._blink(gaze) lo, hi = (3.5, 6.5) if self._state == "thinking" else (2.0, 4.5) t_blink = t + random.uniform(lo, hi) if t >= t_sacc: if self._state == "thinking": gaze = random.choice(["look_left", "look_right", "look_left", "look_right", "neutral"]) hold = random.uniform(0.9, 1.8) elif self._state == "listening": gaze = random.choice(["neutral", "neutral", "neutral", "look_left", "look_right"]) hold = random.uniform(0.5, 1.2) else: # idle — relaxed wandering gaze = random.choice(["neutral", "neutral", "look_left", "look_right", "neutral"]) hold = random.uniform(0.3, 0.9) await self._play(gaze) t_sacc = t + hold + random.uniform(0.4, 1.4) else: await self._play(gaze) if self._state == "idle" and t >= t_micro: # rare idle micro-smile await self._play("smile") await asyncio.sleep(random.uniform(0.6, 1.0)) gaze = "neutral" self._cur = None t_micro = t + random.uniform(15.0, 30.0) await asyncio.sleep(0.05) # --------------------------------------------------------------------------- # Standalone runner # --------------------------------------------------------------------------- async def _amain(args): face = LifelikeFace(name_prefix=args.name_prefix, address=args.address, brightness=args.brightness) print("connecting + loading frames (keep the mask within ~30 cm) ...", flush=True) await face.start(reload=args.reload) print("lifelike motion running. Ctrl+C to stop.", flush=True) try: if args.demo: steps = [ ("idle (wandering + blinks)", lambda: face.set_idle(), 7), ("listening (attentive)", lambda: face.set_listening(), 7), ("thinking (looks away)", lambda: face.set_thinking(), 7), ("speaking (auto lip-sync)", lambda: face.set_speaking(True), 7), ("react: surprised", lambda: face.react("surprised", 2.0), 2.2), ("react: smile", lambda: face.react("smile", 2.0), 2.2), ("react: sad", lambda: face.react("sad", 2.0), 2.2), ("back to idle", lambda: face.set_idle(), 5), ] for label, action, dur in steps: print(" ->", label, flush=True) action() await asyncio.sleep(dur) face.set_idle() await asyncio.sleep(2) else: while True: await asyncio.sleep(1) except KeyboardInterrupt: print("\nstopping ...") finally: await face.stop() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--demo", action="store_true", help="cycle through the states/reactions") ap.add_argument("--reload", action="store_true", help="force re-upload of the frame set") ap.add_argument("--address", help="mask BLE MAC") ap.add_argument("--name-prefix", default="MASK") ap.add_argument("--brightness", type=int, default=95) asyncio.run(_amain(ap.parse_args())) if __name__ == "__main__": main()