commit 7f337cbeb6fcd58aa4142e44f9cff89bf67db82f Author: Alex Kazaiev Date: Fri Jan 2 21:04:21 2026 -0600 Initial commit - original eyes2.py from head-lyra diff --git a/eyes2.py b/eyes2.py new file mode 100644 index 0000000..6543ae6 --- /dev/null +++ b/eyes2.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +from lib import LCD_2inch +import lgpio +from PIL import Image, ImageDraw +import time +import numpy as np +import random +import spidev +import usb.core +import usb.util +from tuning import Tuning + +# === Display Configuration === +RST1 = 27 +DC1 = 25 +BL1 = 18 +RST2 = 22 +DC2 = 23 +BL2 = 24 +bus = 0 + +# === Initialize GPIO Handle === +h = lgpio.gpiochip_open(0) + +# === Initialize Displays === +disp1 = LCD_2inch.LCD_2inch(spi=spidev.SpiDev(bus, 0), rst=RST1, dc=DC1, bl=BL1) +disp1.Init() +disp1.clear() +disp1.bl_DutyCycle(50) + +disp2 = LCD_2inch.LCD_2inch(spi=spidev.SpiDev(bus, 1), rst=RST2, dc=DC2, bl=BL2) +disp2.Init() +disp2.clear() +disp2.bl_DutyCycle(50) + +# === Image and Drawing Setup === +WIDTH = disp1.width +HEIGHT = disp1.height +CENTER = (WIDTH // 2, HEIGHT // 2) + +# === Animation Parameters === +segment_count = 12 +segment_width = 8 +segment_length = 4 +base_iris_radius = 30 +pulse_range = 5 +rotation_speed = 0.002 +sleep_time = 0.05 + +angle_offset = 0 +fixed_outer_radius = 75 + +# === State Management === +class EyeState: + IDLE = "idle" + LISTENING = "listening" + RESPONDING = "responding" + PLEASURE = "pleasure" + +state = EyeState.IDLE +state_timer = time.time() + +# === Idle Color Pulse Setup === +pulse_timer = 0 +pulse_duration = 8.0 + +# === DoA Detection Setup === +dev = usb.core.find(idVendor=0x2886, idProduct=0x0018) +mic_tuning = Tuning(dev) if dev else None + +def get_doa_angle(): + if mic_tuning: + try: + return mic_tuning.direction + except Exception as e: + print(f"DoA read error: {e}") + return None + return None + +last_doa_poll = 0 +iris_offset = (0, 0) +doa_angle = 180 + +# === Flick Timer === +last_flick_time = time.time() +flick_interval = random.uniform(15, 30) +flick_active = False +flick_duration = 0.2 +flick_start = 0 +flick_offset = (0, 0) + +try: + while True: + current_time = time.time() + elapsed = current_time - pulse_timer + + # === Flick logic === + if not flick_active and current_time - last_flick_time > flick_interval: + flick_active = True + flick_start = current_time + flick_offset = (random.randint(-8, 8), random.randint(-4, 4)) + last_flick_time = current_time + flick_interval = random.uniform(15, 30) + + if flick_active: + if current_time - flick_start < flick_duration: + current_offset = flick_offset + else: + flick_active = False + current_offset = iris_offset + else: + current_offset = iris_offset + + # === Poll DoA every 0.5s === + if current_time - last_doa_poll > 0.5: + angle = get_doa_angle() + if angle is not None: + doa_angle = angle + offset_x = int(-20 * np.sin(np.radians(doa_angle))) + offset_y = int(-10 * np.cos(np.radians(doa_angle))) + iris_offset = (offset_x, offset_y) + last_doa_poll = current_time + + # === State-Driven Properties === + if state == EyeState.IDLE: + t = (np.sin(2 * np.pi * elapsed / pulse_duration) + 1) / 2 + iris_color = ( + int((1 - t) * 192 + t * 0), + int((1 - t) * 192 + t * 255), + int((1 - t) * 192 + t * 255) + ) + pulse = np.sin(current_time * 0.5) * pulse_range + elif state == EyeState.LISTENING: + iris_color = (160, 255, 255) + pulse = np.sin(current_time * 4) * (pulse_range * 0.5) + elif state == EyeState.RESPONDING: + iris_color = (100, 220, 255) + pulse = np.sin(current_time * 5) * (pulse_range * 1.5) + elif state == EyeState.PLEASURE: + iris_color = (180, 180, 255) + pulse = np.sin(current_time * 2) * (pulse_range * 0.5) + else: + iris_color = (192, 192, 192) + pulse = 0 + + iris_radius = base_iris_radius + pulse + center_offset = (CENTER[0] + current_offset[0], CENTER[1] + current_offset[1]) + + img = Image.new("RGB", (WIDTH, HEIGHT), (10, 0, 20)) + draw = ImageDraw.Draw(img) + + for r in range(int(fixed_outer_radius), int(iris_radius), -2): + alpha = int(255 * (1 - (r - iris_radius) / (fixed_outer_radius - iris_radius))) + color = tuple((c * alpha // 255) for c in iris_color) + draw.ellipse( + [center_offset[0] - r, center_offset[1] - r, center_offset[0] + r, center_offset[1] + r], + fill=color + ) + + draw.ellipse( + [center_offset[0] - iris_radius, center_offset[1] - iris_radius, + center_offset[0] + iris_radius, center_offset[1] + iris_radius], + fill=iris_color + ) + + # Draw segmented outer ring as small arcs (platinum, fixed size) + platinum = (192, 192, 192) + arc_extent = 360 / segment_count * 0.6 + for i in range(segment_count): + start_angle = np.degrees(angle_offset + 2 * np.pi * i / segment_count) + bbox = [ + center_offset[0] - fixed_outer_radius, + center_offset[1] - fixed_outer_radius, + center_offset[0] + fixed_outer_radius, + center_offset[1] + fixed_outer_radius + ] + draw.arc(bbox, start=start_angle, end=start_angle + arc_extent, fill=platinum, width=segment_width) + + angle_offset += rotation_speed + + disp1.ShowImage(img) + disp2.ShowImage(img.transpose(Image.ROTATE_180)) + time.sleep(sleep_time) + +except KeyboardInterrupt: + disp1.module_exit() + disp2.module_exit() + lgpio.gpiochip_close(h) + print("Animation stopped.") diff --git a/tuning.py b/tuning.py new file mode 100755 index 0000000..3adef75 --- /dev/null +++ b/tuning.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +import sys +import struct +import usb.core +import usb.util + +USAGE = """Usage: python {} -h + -p show all parameters + -r read all parameters + NAME get the parameter with the NAME + NAME VALUE set the parameter with the NAME and the VALUE +""" + + + +# parameter list +# name: (id, offset, type, max, min , r/w, info) +PARAMETERS = { + 'AECFREEZEONOFF': (18, 7, 'int', 1, 0, 'rw', 'Adaptive Echo Canceler updates inhibit.', '0 = Adaptation enabled', '1 = Freeze adaptation, filter only'), + 'AECNORM': (18, 19, 'float', 16, 0.25, 'rw', 'Limit on norm of AEC filter coefficients'), + 'AECPATHCHANGE': (18, 25, 'int', 1, 0, 'ro', 'AEC Path Change Detection.', '0 = false (no path change detected)', '1 = true (path change detected)'), + 'RT60': (18, 26, 'float', 0.9, 0.25, 'ro', 'Current RT60 estimate in seconds'), + 'HPFONOFF': (18, 27, 'int', 3, 0, 'rw', 'High-pass Filter on microphone signals.', '0 = OFF', '1 = ON - 70 Hz cut-off', '2 = ON - 125 Hz cut-off', '3 = ON - 180 Hz cut-off'), + 'RT60ONOFF': (18, 28, 'int', 1, 0, 'rw', 'RT60 Estimation for AES. 0 = OFF 1 = ON'), + 'AECSILENCELEVEL': (18, 30, 'float', 1, 1e-09, 'rw', 'Threshold for signal detection in AEC [-inf .. 0] dBov (Default: -80dBov = 10log10(1x10-8))'), + 'AECSILENCEMODE': (18, 31, 'int', 1, 0, 'ro', 'AEC far-end silence detection status. ', '0 = false (signal detected) ', '1 = true (silence detected)'), + 'AGCONOFF': (19, 0, 'int', 1, 0, 'rw', 'Automatic Gain Control. ', '0 = OFF ', '1 = ON'), + 'AGCMAXGAIN': (19, 1, 'float', 1000, 1, 'rw', 'Maximum AGC gain factor. ', '[0 .. 60] dB (default 30dB = 20log10(31.6))'), + 'AGCDESIREDLEVEL': (19, 2, 'float', 0.99, 1e-08, 'rw', 'Target power level of the output signal. ', '[-inf .. 0] dBov (default: -23dBov = 10log10(0.005))'), + 'AGCGAIN': (19, 3, 'float', 1000, 1, 'rw', 'Current AGC gain factor. ', '[0 .. 60] dB (default: 0.0dB = 20log10(1.0))'), + 'AGCTIME': (19, 4, 'float', 1, 0.1, 'rw', 'Ramps-up / down time-constant in seconds.'), + 'CNIONOFF': (19, 5, 'int', 1, 0, 'rw', 'Comfort Noise Insertion.', '0 = OFF', '1 = ON'), + 'FREEZEONOFF': (19, 6, 'int', 1, 0, 'rw', 'Adaptive beamformer updates.', '0 = Adaptation enabled', '1 = Freeze adaptation, filter only'), + 'STATNOISEONOFF': (19, 8, 'int', 1, 0, 'rw', 'Stationary noise suppression.', '0 = OFF', '1 = ON'), + 'GAMMA_NS': (19, 9, 'float', 3, 0, 'rw', 'Over-subtraction factor of stationary noise. min .. max attenuation'), + 'MIN_NS': (19, 10, 'float', 1, 0, 'rw', 'Gain-floor for stationary noise suppression.', '[-inf .. 0] dB (default: -16dB = 20log10(0.15))'), + 'NONSTATNOISEONOFF': (19, 11, 'int', 1, 0, 'rw', 'Non-stationary noise suppression.', '0 = OFF', '1 = ON'), + 'GAMMA_NN': (19, 12, 'float', 3, 0, 'rw', 'Over-subtraction factor of non- stationary noise. min .. max attenuation'), + 'MIN_NN': (19, 13, 'float', 1, 0, 'rw', 'Gain-floor for non-stationary noise suppression.', '[-inf .. 0] dB (default: -10dB = 20log10(0.3))'), + 'ECHOONOFF': (19, 14, 'int', 1, 0, 'rw', 'Echo suppression.', '0 = OFF', '1 = ON'), + 'GAMMA_E': (19, 15, 'float', 3, 0, 'rw', 'Over-subtraction factor of echo (direct and early components). min .. max attenuation'), + 'GAMMA_ETAIL': (19, 16, 'float', 3, 0, 'rw', 'Over-subtraction factor of echo (tail components). min .. max attenuation'), + 'GAMMA_ENL': (19, 17, 'float', 5, 0, 'rw', 'Over-subtraction factor of non-linear echo. min .. max attenuation'), + 'NLATTENONOFF': (19, 18, 'int', 1, 0, 'rw', 'Non-Linear echo attenuation.', '0 = OFF', '1 = ON'), + 'NLAEC_MODE': (19, 20, 'int', 2, 0, 'rw', 'Non-Linear AEC training mode.', '0 = OFF', '1 = ON - phase 1', '2 = ON - phase 2'), + 'SPEECHDETECTED': (19, 22, 'int', 1, 0, 'ro', 'Speech detection status.', '0 = false (no speech detected)', '1 = true (speech detected)'), + 'FSBUPDATED': (19, 23, 'int', 1, 0, 'ro', 'FSB Update Decision.', '0 = false (FSB was not updated)', '1 = true (FSB was updated)'), + 'FSBPATHCHANGE': (19, 24, 'int', 1, 0, 'ro', 'FSB Path Change Detection.', '0 = false (no path change detected)', '1 = true (path change detected)'), + 'TRANSIENTONOFF': (19, 29, 'int', 1, 0, 'rw', 'Transient echo suppression.', '0 = OFF', '1 = ON'), + 'VOICEACTIVITY': (19, 32, 'int', 1, 0, 'ro', 'VAD voice activity status.', '0 = false (no voice activity)', '1 = true (voice activity)'), + 'STATNOISEONOFF_SR': (19, 33, 'int', 1, 0, 'rw', 'Stationary noise suppression for ASR.', '0 = OFF', '1 = ON'), + 'NONSTATNOISEONOFF_SR': (19, 34, 'int', 1, 0, 'rw', 'Non-stationary noise suppression for ASR.', '0 = OFF', '1 = ON'), + 'GAMMA_NS_SR': (19, 35, 'float', 3, 0, 'rw', 'Over-subtraction factor of stationary noise for ASR. ', '[0.0 .. 3.0] (default: 1.0)'), + 'GAMMA_NN_SR': (19, 36, 'float', 3, 0, 'rw', 'Over-subtraction factor of non-stationary noise for ASR. ', '[0.0 .. 3.0] (default: 1.1)'), + 'MIN_NS_SR': (19, 37, 'float', 1, 0, 'rw', 'Gain-floor for stationary noise suppression for ASR.', '[-inf .. 0] dB (default: -16dB = 20log10(0.15))'), + 'MIN_NN_SR': (19, 38, 'float', 1, 0, 'rw', 'Gain-floor for non-stationary noise suppression for ASR.', '[-inf .. 0] dB (default: -10dB = 20log10(0.3))'), + 'GAMMAVAD_SR': (19, 39, 'float', 1000, 0, 'rw', 'Set the threshold for voice activity detection.', '[-inf .. 60] dB (default: 3.5dB 20log10(1.5))'), + # 'KEYWORDDETECT': (20, 0, 'int', 1, 0, 'ro', 'Keyword detected. Current value so needs polling.'), + 'DOAANGLE': (21, 0, 'int', 359, 0, 'ro', 'DOA angle. Current value. Orientation depends on build configuration.') +} + + +class Tuning: + TIMEOUT = 100000 + + def __init__(self, dev): + self.dev = dev + + def write(self, name, value): + try: + data = PARAMETERS[name] + except KeyError: + return + + if data[5] == 'ro': + raise ValueError('{} is read-only'.format(name)) + + id = data[0] + + # 4 bytes offset, 4 bytes value, 4 bytes type + if data[2] == 'int': + payload = struct.pack(b'iii', data[1], int(value), 1) + else: + payload = struct.pack(b'ifi', data[1], float(value), 0) + + self.dev.ctrl_transfer( + usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE, + 0, 0, id, payload, self.TIMEOUT) + + def read(self, name): + try: + data = PARAMETERS[name] + except KeyError: + return + + id = data[0] + + cmd = 0x80 | data[1] + if data[2] == 'int': + cmd |= 0x40 + + length = 8 + + response = self.dev.ctrl_transfer( + usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE, + 0, cmd, id, length, self.TIMEOUT) + + response = struct.unpack(b'ii', response.tobytes()) + + if data[2] == 'int': + result = response[0] + else: + result = response[0] * (2.**response[1]) + + return result + + def set_vad_threshold(self, db): + self.write('GAMMAVAD_SR', db) + + def is_voice(self): + return self.read('VOICEACTIVITY') + + @property + def direction(self): + return self.read('DOAANGLE') + + @property + def version(self): + return self.dev.ctrl_transfer( + usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE, + 0, 0x80, 0, 1, self.TIMEOUT)[0] + + def close(self): + """ + close the interface + """ + usb.util.dispose_resources(self.dev) + + +def find(vid=0x2886, pid=0x0018): + dev = usb.core.find(idVendor=vid, idProduct=pid) + if not dev: + return + + # configuration = dev.get_active_configuration() + + # interface_number = None + # for interface in configuration: + # interface_number = interface.bInterfaceNumber + + # if dev.is_kernel_driver_active(interface_number): + # dev.detach_kernel_driver(interface_number) + + return Tuning(dev) + + + +def main(): + if len(sys.argv) > 1: + if sys.argv[1] == '-p': + print('name\t\t\ttype\tmax\tmin\tr/w\tinfo') + print('-------------------------------') + for name in sorted(PARAMETERS.keys()): + data = PARAMETERS[name] + print('{:16}\t{}'.format(name, '\t'.join([str(i) for i in data[2:7]]))) + for extra in data[7:]: + print('{}{}'.format(' '*60, extra)) + else: + dev = find() + if not dev: + print('No device found') + sys.exit(1) + + # print('version: {}'.format(dev.version)) + + if sys.argv[1] == '-r': + print('{:24} {}'.format('name', 'value')) + print('-------------------------------') + for name in sorted(PARAMETERS.keys()): + print('{:24} {}'.format(name, dev.read(name))) + else: + name = sys.argv[1].upper() + if name in PARAMETERS: + if len(sys.argv) > 2: + dev.write(name, sys.argv[2]) + + print('{}: {}'.format(name, dev.read(name))) + else: + print('{} is not a valid name'.format(name)) + + dev.close() + else: + print(USAGE.format(sys.argv[0])) + +if __name__ == '__main__': + main()