From 4f3376dc665f299c3c9463b180d3d45e76f7a679 Mon Sep 17 00:00:00 2001 From: Azalea <22280294+hykilpikonna@users.noreply.github.com> Date: Thu, 8 Aug 2024 23:48:32 -0400 Subject: [PATCH] [+] Mai touch taiko controller --- MaiTouchTaiko.py | 247 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 MaiTouchTaiko.py diff --git a/MaiTouchTaiko.py b/MaiTouchTaiko.py new file mode 100644 index 0000000..9b0ae13 --- /dev/null +++ b/MaiTouchTaiko.py @@ -0,0 +1,247 @@ +# Requirements: pyserial, hypy_utils, pygame +import ctypes +import math +import os +import threading +from typing import NamedTuple, Literal + +import pygame +import serial +from hypy_utils.logging_utils import setup_logger + +log = setup_logger() + + +class TouchArea(NamedTuple): + category: Literal['A', 'B', 'C', 'D', 'E'] + index: int + + def __str__(self): + return self.category + str(self.index) + + +touch_areas = ( + [TouchArea('A', i + 1) for i in range(8)] + + [TouchArea('B', i + 1) for i in range(8)] + + [TouchArea('C', i + 1) for i in range(2)] + + [TouchArea('D', i + 1) for i in range(8)] + + [TouchArea('E', i + 1) for i in range(8)] +) + + +def convert(d: str) -> list[TouchArea]: + """ + Convert maimai touch data string into touch areas activated. + + The maimai touch data is a letter representation of bitwise data. + + When nothing is touched, d = "@@@@@@@" + When A1 is touched, d = "A@@@@@@" + When A2 is touched, d = "B@@@@@@" + When A3 is touched, d = "D@@@@@@" + When both A1 and A2 are touched, d = "C@@@@@@" (A + B = C) + When both A1 and A3 are touched, d = "E@@@@@@" (A + D = E) + When all three are touched, d = "G@@@@@@" (A + B + D = G) + + Raw letter orders are A (1) B (10) D (100) H (1000) P (10000) + + :param d: Maimai touch data (single frame) + :return: List of touch areas activated + """ + # Loop through each digit + for ci, c in enumerate(d): + if c == "@": + continue + + # Convert it to ordinal number (A = 1) + # The binary of oi will show 5 bits, each representing if the touch area is activated. + oi = ord(c) - ord('A') + 1 + + # Find bits activated + for i in range(5): + if oi & (1 << i): + yield touch_areas[ci * 5 + i] + + +def serial_listen(callback: callable, port: str = 'COM3'): + """ + Listen for serial data from mai touch device. + + Official port: COM3 + Baud rate: 9600 + Frame format: (@@@@@@@) + + :param callback: Callback function when touch data is received + :param port: Serial port to listen to + """ + # Open serial + ser = serial.Serial(port, 9600) + + # Align frame + while ser.read(1).decode('utf-8') != ')': + pass + + # Loop forever + frame = '' + while True: + # Read a frame + frame += ser.read(9).decode('utf-8') + log.debug(f"Received frame: {frame}") + + assert frame[0] == '(' + assert frame[-1] == ')' + assert len(frame) == 9 + + # Parse + ta = list(convert(frame[1:-1])) + frame = '' + + # Callback + callback(ta) + + +def serial_listen_btn(callback: callable, port: str = 'COM25'): + """ + Listen for serial data from mai_pico io4 buttons. + + Frame format: BTN 000000000000;\n + + :param callback: Callback function when button state changes + :param port: Serial port to listen to + """ + # Open serial + ser = serial.Serial(port, 9600) + + # Loop forever + while True: + ser.read_until(b'BTN ') + frame = ser.read_until(b';').decode('utf-8') + log.debug(f"Received frame: {frame}") + + # Callback + callback(frame.strip(';')) + + +def test_callback(ta: list[TouchArea]): + log.info(f"Received touch areas: {' '.join(map(str, ta))}") + + +def test_callback_btn(btn: str): + log.info(f"Received button state: {btn}") + + +# Configuration: VK codes for DFJK +TAIKO_KEYS = [0x44, 0x46, 0x4A, 0x4B] +SCAN_CODES = [ctypes.windll.user32.MapVirtualKeyA(k, 0) for k in TAIKO_KEYS] + +# State and macros +TAIKO_STATES = [False] * len(TAIKO_KEYS) +TAIKO_LEFT_KA = 0 +TAIKO_LEFT_DON = 1 +TAIKO_RIGHT_DON = 2 +TAIKO_RIGHT_KA = 3 + + +def taiko_callback(ta: list[TouchArea]): + """ + Convert mai touch to taiko keyboard input + + D1, D5, E1, E5 are ignored (they're located in the center + + A1-4, B1-4, D2-4, E2-4, C1 are right Don + Others are left Don + """ + states = [False] * len(TAIKO_KEYS) + for t in ta: + if str(t) in ('D1', 'D5', 'E1', 'E5'): + continue + if (t.category in 'ABDE' and t.index in (1, 2, 3, 4)) or \ + (t.category == 'C' and t.index == 1): + states[TAIKO_RIGHT_DON] = True + else: + states[TAIKO_LEFT_DON] = True + taiko_after_update(states) + + +def taiko_after_update(states: list[bool], is_btn: bool = False): + # Check state changes + for i, (old, new) in enumerate(zip(TAIKO_STATES, states)): + if ((is_btn and i in (TAIKO_LEFT_DON, TAIKO_RIGHT_DON)) or + (not is_btn and i in (TAIKO_LEFT_KA, TAIKO_RIGHT_KA))): + continue + if old == new: + continue + + TAIKO_STATES[i] = new + # Send key up/down + ctypes.windll.user32.keybd_event(TAIKO_KEYS[i], SCAN_CODES[i], 0 if new else 2, 0) + + +def taiko_callback_btn(btn: str): + """ + Convert mai_pico io4 buttons to taiko keyboard input + + Button index 0-3 are right Ka, 4-7 are left Ka + """ + print(btn) + states = [False] * len(TAIKO_KEYS) + states[TAIKO_RIGHT_KA] = '1' in btn[0:4] + states[TAIKO_LEFT_KA] = '1' in btn[4:8] + taiko_after_update(states, True) + + +def start_serial_threads(): + thread1 = threading.Thread(target=serial_listen, args=(taiko_callback, 'COM3')) + thread2 = threading.Thread(target=serial_listen_btn, args=(taiko_callback_btn, 'COM25')) + thread1.start() + thread2.start() + + +if __name__ == '__main__': + # Default window location at bottom + os.environ['SDL_VIDEO_WINDOW_POS'] = f'0,{1920-1080}' + + # Start pygame window + pygame.init() + W = 1080 + CW = 0.8 + pygame.display.set_mode((W, W)) + pygame.display.set_caption("MaiTouch") + + # Start serial threads + start_serial_threads() + + COLOR_DON_OFF = '#fc9f9f' + COLOR_DON_ON = '#ff4242' + COLOR_KA_OFF = '#9fe3fc' + COLOR_KA_ON = '#0f68f7' + DELTA_DEG = 0.1 + + def color(idx): + if idx in (TAIKO_LEFT_KA, TAIKO_RIGHT_KA): + return COLOR_KA_ON if TAIKO_STATES[idx] else COLOR_KA_OFF + return COLOR_DON_ON if TAIKO_STATES[idx] else COLOR_DON_OFF + + # Game loop + try: + while True: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + os._exit(0) + + # White background + pygame.display.get_surface().fill((255, 255, 255)) + # Taiko Don (two red half-circles in the center) + mar = (W * (1 - CW)) / 2 + pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_LEFT_DON), (mar, mar, W * CW, W * CW), math.pi / 2, - math.pi / 2, 5000) + pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_RIGHT_DON), (mar, mar, W * CW, W * CW), - math.pi / 2, math.pi / 2, 5000) + # Taiko Ka (two blue arches (empty half-circles) on the sides) + pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_LEFT_KA), (0, 0, W, W), math.pi / 2, - math.pi / 2, 80) + pygame.draw.arc(pygame.display.get_surface(), color(TAIKO_RIGHT_KA), (0, 0, W, W), - math.pi / 2, math.pi / 2, 80) + # Thin rectangle dividing the middle + rw = 20 + pygame.draw.rect(pygame.display.get_surface(), '#FFFFFF', (W / 2 - rw / 2, 0, rw, W)) + pygame.display.flip() + + except KeyboardInterrupt: + os._exit(0)