From 0ac2d46bf4c93e58fb74d0cd081ec960f078d43b Mon Sep 17 00:00:00 2001 From: 11095 <3082891408@qq.com> Date: Thu, 4 Jun 2026 01:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=9B=B4=E5=A5=BD=E7=9A=84=E6=BC=94?= =?UTF-8?q?=E5=A5=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/custom/action/auto_piano/action.py | 6 + agent/custom/action/auto_piano/key_mapping.py | 158 +++++++--- .../custom/action/auto_piano/maa_keyboard.py | 236 +++++++++++++-- .../action/auto_piano/midi_processor.py | 148 ++++++++- agent/custom/action/auto_piano/player.py | 285 +++++++++++++++--- assets/resource/locales/interface/en_us.json | 12 + assets/resource/locales/interface/ja_jp.json | 12 + assets/resource/locales/interface/ko_kr.json | 12 + assets/resource/locales/interface/zh_cn.json | 18 ++ assets/resource/locales/interface/zh_tw.json | 12 + assets/resource/tasks/AutoPiano.json | 62 +++- 11 files changed, 843 insertions(+), 118 deletions(-) diff --git a/agent/custom/action/auto_piano/action.py b/agent/custom/action/auto_piano/action.py index 8bc26941..2d772508 100644 --- a/agent/custom/action/auto_piano/action.py +++ b/agent/custom/action/auto_piano/action.py @@ -57,5 +57,11 @@ def _run(self, context: Context, argv: CustomAction.RunArg) -> None: song=str(param.get("song", "")).strip(), speed=float(param.get("speed", 1.0)), transpose=int(param.get("transpose", 0)), + key_mode=str(param.get("key_mode", "36")).strip(), + tracks=str(param.get("tracks", "all")).strip(), + out_of_range_mode=str(param.get("out_of_range_mode", "fold")).strip(), + sustain=str(param.get("sustain", "true")).strip().lower() in ("true", "1", "on", "yes"), + sustain_mode=str(param.get("sustain_mode", "repeat")).strip().lower(), + sustain_repeat_rate=str(param.get("sustain_repeat_rate", "fast")).strip().lower(), ) AutoPianoPlayer(PROJECT_ROOT).play(context, settings) diff --git a/agent/custom/action/auto_piano/key_mapping.py b/agent/custom/action/auto_piano/key_mapping.py index 15724049..470313e4 100644 --- a/agent/custom/action/auto_piano/key_mapping.py +++ b/agent/custom/action/auto_piano/key_mapping.py @@ -1,41 +1,121 @@ +"""异环钢琴键位映射 + +游戏钢琴物理布局(3 个八度 × 12 音阶 = 36 键): + 高音:Q W E R T Y U + Shift/Ctrl 半音 + 中音:A S D F G H J + Shift/Ctrl 半音 + 低音:Z X C V B N M + Shift/Ctrl 半音 + +半音规则: + Shift + 白键 = 升半音 (#) + Ctrl + 白键 = 降半音 (b) +""" + +# ========================================== +# 36键完整映射(含半音) +# ========================================== NOTE_KEY_MAPPING = { - # Low octave: Z-M - 60: "z", - 61: "shift+z", - 62: "x", - 63: "ctrl+c", - 64: "c", - 65: "v", - 66: "shift+v", - 67: "b", - 68: "shift+b", - 69: "n", - 70: "ctrl+m", - 71: "m", - # Middle octave: A-J - 72: "a", - 73: "shift+a", - 74: "s", - 75: "ctrl+d", - 76: "d", - 77: "f", - 78: "shift+f", - 79: "g", - 80: "shift+g", - 81: "h", - 82: "ctrl+j", - 83: "j", - # High octave: Q-U - 84: "q", - 85: "shift+q", - 86: "w", - 87: "ctrl+e", - 88: "e", - 89: "r", - 90: "shift+r", - 91: "t", - 92: "shift+t", - 93: "y", - 94: "ctrl+u", - 95: "u", + # ---- 低音 C4 (MIDI 60) ~ B4 (71) ---- + 60: "z", # C4 + 61: "shift+z", # C#4 + 62: "x", # D4 + 63: "ctrl+c", # D#4 / Eb4 + 64: "c", # E4 + 65: "v", # F4 + 66: "shift+v", # F#4 + 67: "b", # G4 + 68: "shift+b", # G#4 + 69: "n", # A4 + 70: "ctrl+m", # A#4 / Bb4 + 71: "m", # B4 + + # ---- 中音 C5 (72) ~ B5 (83) ---- + 72: "a", # C5 + 73: "shift+a", # C#5 + 74: "s", # D5 + 75: "ctrl+d", # D#5 / Eb5 + 76: "d", # E5 + 77: "f", # F5 + 78: "shift+f", # F#5 + 79: "g", # G5 + 80: "shift+g", # G#5 + 81: "h", # A5 + 82: "ctrl+j", # A#5 / Bb5 + 83: "j", # B5 + + # ---- 高音 C6 (84) ~ B6 (95) ---- + 84: "q", # C6 + 85: "shift+q", # C#6 + 86: "w", # D6 + 87: "ctrl+e", # D#6 / Eb6 + 88: "e", # E6 + 89: "r", # F6 + 90: "shift+r", # F#6 + 91: "t", # G6 + 92: "shift+t", # G#6 + 93: "y", # A6 + 94: "ctrl+u", # A#6 / Bb6 + 95: "u", # B6 } + + +# ========================================== +# 21键白键映射(不含半音,简单模式) +# ========================================== +NOTE_KEY_MAPPING_WHITE = { + # 低音 C4~B4 + 60: "z", 62: "x", 64: "c", 65: "v", 67: "b", 69: "n", 71: "m", + # 中音 C5~B5 + 72: "a", 74: "s", 76: "d", 77: "f", 79: "g", 81: "h", 83: "j", + # 高音 C6~B6 + 84: "q", 86: "w", 88: "e", 89: "r", 91: "t", 93: "y", 95: "u", +} + + +# ========================================== +# 工具函数 +# ========================================== + +# 12音阶中的白键偏移量(C=0, D=2, E=4, F=5, G=7, A=9, B=11) +_WHITE_KEY_OFFSETS = frozenset({0, 2, 4, 5, 7, 9, 11}) + + +def is_white_key(midi_pitch: int) -> bool: + """判断 MIDI 音高是否为白键(自然音)""" + return (midi_pitch % 12) in _WHITE_KEY_OFFSETS + + +def snap_to_white_key(midi_pitch: int) -> int: + """ + 将任意 MIDI 音高映射到最近的白键。 + 若距离两侧白键相等,优先向低音方向取(向下取整)。 + """ + offset = midi_pitch % 12 + + if offset in _WHITE_KEY_OFFSETS: + return midi_pitch + + # 黑键:寻找最近的白键,优先向下(低音方向) + for delta in range(1, 12): + lower = (offset - delta) % 12 + if lower in _WHITE_KEY_OFFSETS: + return midi_pitch - delta + upper = (offset + delta) % 12 + if upper in _WHITE_KEY_OFFSETS: + return midi_pitch + delta + + # 理论上不可能到达此处 + return midi_pitch + + +def get_mapping(key_mode: str = "36") -> dict[int, str]: + """ + 根据键位模式返回对应的映射表。 + + Args: + key_mode: + - "36" : 完整 36 键(含半音,默认) + - "21" : 仅白键 21 键(半音会被映射到最近白键) + """ + if key_mode == "21": + return NOTE_KEY_MAPPING_WHITE + return NOTE_KEY_MAPPING diff --git a/agent/custom/action/auto_piano/maa_keyboard.py b/agent/custom/action/auto_piano/maa_keyboard.py index 6d333548..0fd547f9 100644 --- a/agent/custom/action/auto_piano/maa_keyboard.py +++ b/agent/custom/action/auto_piano/maa_keyboard.py @@ -50,10 +50,12 @@ # ========================================== -def get_lparam(vk_code, is_down=True): +def get_lparam(vk_code, is_down=True, repeat=False): """构建底层硬件扫描码""" scan_code = user32.MapVirtualKeyW(vk_code, 0) lparam = 1 | (scan_code << 16) + if is_down and repeat: + lparam |= 0x40000000 if not is_down: lparam |= 0xC0000000 return lparam @@ -61,37 +63,237 @@ def get_lparam(vk_code, is_down=True): class MaaKeyboardBridge: def __init__( - self, controller=None, hold_seconds: float = 0.008, wait_jobs: bool = False + self, + controller=None, + hold_seconds: float = 0.008, + wait_jobs: bool = False, + mapping: dict | None = None, + sustain_mode: str = "repeat", + repeat_interval: float = 0.045, ): - self.mapping = NOTE_KEY_MAPPING + self.controller = controller + self.mapping = mapping if mapping is not None else NOTE_KEY_MAPPING self.hold_seconds = hold_seconds + self.wait_jobs = wait_jobs + self.sustain_mode = sustain_mode if sustain_mode in ("repeat", "hold") else "repeat" + self.repeat_interval = min(max(float(repeat_interval), 0.015), 0.2) self.hwnd = 0 - # 按顺序遍历列表,寻找第一个存在的游戏窗口 - for title in WINDOW_TITLES: - hwnd = user32.FindWindowW(None, title) - if hwnd: - self.hwnd = hwnd - logger.info("已连接到游戏窗口: '%s' (HWND: %s)", title, self.hwnd) - break # 找到了就立刻停止搜索 + # 跟踪当前按下的音符引用计数和修饰键引用计数 + self._active_counts: dict[int, int] = {} # midi_note -> ref_count + self._active_modifiers: dict[int, int] = {} # vk -> ref_count + self._last_refresh = 0.0 - if not self.hwnd: - logger.warning("未找到列表中的任何窗口,请检查游戏是否运行!列表: %s", WINDOW_TITLES) + if self.controller is not None: + logger.info( + "自动钢琴使用 Maa controller 发送按键(延音模式: {}, 补发间隔: {:.0f}ms)", + self.sustain_mode, + self.repeat_interval * 1000, + ) + + if self.controller is None or self.sustain_mode == "repeat": + # 按顺序遍历列表,寻找第一个存在的游戏窗口 + for title in WINDOW_TITLES: + hwnd = user32.FindWindowW(None, title) + if hwnd: + self.hwnd = hwnd + logger.info("已连接到游戏窗口: '{}' (HWND: {})", title, self.hwnd) + break # 找到了就立刻停止搜索 + + if not self.hwnd: + logger.warning("未找到列表中的任何窗口,请检查游戏是否运行!列表: {}", WINDOW_TITLES) - def _force_send_key(self, vk_code, is_down): + # ------------------------------------------------------------------ + # 底层发送 + # ------------------------------------------------------------------ + def _force_send_key(self, vk_code, is_down, repeat=False): """带有强行唤醒的底层发送器""" if not self.hwnd: return - # 发送前强行给窗口发一个“鼠标点击激活”信号,骗过后台检测! - user32.SendMessageW(self.hwnd, WM_ACTIVATE, WA_CLICKACTIVE, 0) + # 发送前给窗口发一个 WM_ACTIVATE 信号,骗过后台检测。 + # 使用 PostMessageW(异步)避免阻塞,且每 50ms 最多发一次防止消息堆积。 + now = time.perf_counter() + if now - getattr(self, "_last_activate", 0) > 0.05: + user32.PostMessageW(self.hwnd, WM_ACTIVATE, WA_CLICKACTIVE, 0) + self._last_activate = now - lparam = get_lparam(vk_code, is_down) + lparam = get_lparam(vk_code, is_down, repeat=repeat) msg = WM_KEYDOWN if is_down else WM_KEYUP user32.PostMessageW(self.hwnd, msg, vk_code, lparam) + def _send_key(self, vk_code, is_down, repeat=False): + """优先通过 Maa controller 发送按键,失败时回退到窗口消息。""" + if self.controller is not None: + try: + job = ( + self.controller.post_key_down(vk_code) + if is_down + else self.controller.post_key_up(vk_code) + ) + if self.wait_jobs and job is not None: + job.wait() + return + except Exception: + logger.exception("Maa controller 发送按键失败,回退到 PostMessage") + self.controller = None + + self._force_send_key(vk_code, is_down, repeat=repeat) + + @staticmethod + def _parse_action(action: str) -> tuple[str | None, str]: + """解析键位动作字符串,例如 'shift+z' -> ('shift', 'z')""" + if "+" in action: + mod, key = action.split("+", 1) + return mod, key + return None, action + + # ------------------------------------------------------------------ + # 单音符控制(支持延音) + # ------------------------------------------------------------------ + def press_note(self, midi_note: int) -> None: + """按下一个音符(引用计数管理,同一音符多次按下不会重复发送 key down)。""" + if midi_note not in self.mapping or (self.controller is None and not self.hwnd): + return + + action = self.mapping[midi_note] + modifier, key = self._parse_action(action) + + vk = WIN32_VK.get(key) + if vk is None: + return + + # 引用计数 +1 + self._active_counts[midi_note] = self._active_counts.get(midi_note, 0) + 1 + if self._active_counts[midi_note] > 1: + # 已经有其他声部在按这个 MIDI 音符,不需要重复发送 key down + return + + mod_vk = WIN32_VK.get(modifier) if modifier else None + + # 检查是否有其他音符使用同一个物理键(base key) + other_using_same_key = None + other_mod_vk = None + for other_note, count in self._active_counts.items(): + if other_note != midi_note and count > 0: + other_action = self.mapping[other_note] + other_mod, other_key = self._parse_action(other_action) + if other_key == key: + other_using_same_key = other_note + other_mod_vk = WIN32_VK.get(other_mod) if other_mod else None + break + + if other_using_same_key is not None: + # 同一个 base key 上已经有其他音符在按着 + # 只需要调整 modifier(如果有变化),不需要重复发送 key down + if other_mod_vk != mod_vk: + # 释放旧的 modifier + if other_mod_vk and other_mod_vk in self._active_modifiers: + self._active_modifiers[other_mod_vk] -= 1 + if self._active_modifiers[other_mod_vk] <= 0: + self._send_key(other_mod_vk, False) + self._active_modifiers[other_mod_vk] = 0 + # 按下新的 modifier + if mod_vk: + if mod_vk not in self._active_modifiers or self._active_modifiers[mod_vk] == 0: + self._send_key(mod_vk, True) + self._active_modifiers[mod_vk] = self._active_modifiers.get(mod_vk, 0) + 1 + return + + # 正常流程:base key 没有被其他音符使用 + if mod_vk: + if mod_vk not in self._active_modifiers or self._active_modifiers[mod_vk] == 0: + self._send_key(mod_vk, True) + self._active_modifiers[mod_vk] = self._active_modifiers.get(mod_vk, 0) + 1 + + self._send_key(vk, True) + + def release_note(self, midi_note: int) -> None: + """抬起一个音符(引用计数管理,只有当所有声部都释放后才真正发送 key up)。""" + if midi_note not in self._active_counts or midi_note not in self.mapping: + return + + # 引用计数 -1 + self._active_counts[midi_note] -= 1 + if self._active_counts[midi_note] > 0: + # 还有其他声部在按这个 MIDI 音符,不要发送 key up + return + + del self._active_counts[midi_note] + + action = self.mapping[midi_note] + modifier, key = self._parse_action(action) + + vk = WIN32_VK.get(key) + if vk is None: + return + + # 检查是否有其他活动音符使用同一个物理键 + # (例如 C=z 和 C#=shift+z 共享 z 键,释放 C 时不应该发送 z up) + for other_note in self._active_counts: + other_action = self.mapping[other_note] + other_mod, other_key = self._parse_action(other_action) + if other_key == key: + # 有其他音符在使用同一个物理键,不释放 base key + # 但需要释放当前音符的 modifier(如果有),防止泄漏 + mod_vk = WIN32_VK.get(modifier) if modifier else None + if mod_vk and mod_vk in self._active_modifiers and self._active_modifiers[mod_vk] > 0: + self._active_modifiers[mod_vk] -= 1 + if self._active_modifiers[mod_vk] <= 0: + self._send_key(mod_vk, False) + self._active_modifiers[mod_vk] = 0 + return + + self._send_key(vk, False) + + # 修饰键引用计数 -1,归零时抬起 + mod_vk = WIN32_VK.get(modifier) if modifier else None + if mod_vk and mod_vk in self._active_modifiers and self._active_modifiers[mod_vk] > 0: + self._active_modifiers[mod_vk] -= 1 + if self._active_modifiers[mod_vk] <= 0: + self._send_key(mod_vk, False) + self._active_modifiers[mod_vk] = 0 + + def release_all(self) -> None: + """抬起所有当前按下的音符(安全清理)。""" + for note in list(self._active_counts.keys()): + self._active_counts[note] = 1 + self.release_note(note) + + def refresh_active_keys(self, force: bool = False) -> None: + """刷新仍在延音中的按键,避免游戏窗口长按状态超时丢失。""" + if self.sustain_mode != "repeat": + return + if not self.hwnd or not self._active_counts: + return + + now = time.perf_counter() + if not force and now - self._last_refresh < self.repeat_interval: + return + self._last_refresh = now + + for vk, count in list(self._active_modifiers.items()): + if count > 0: + self._force_send_key(vk, True, repeat=True) + + base_keys: set[int] = set() + for note, count in list(self._active_counts.items()): + if count <= 0 or note not in self.mapping: + continue + _, key = self._parse_action(self.mapping[note]) + vk = WIN32_VK.get(key) + if vk is not None: + base_keys.add(vk) + + for vk in base_keys: + self._force_send_key(vk, False) + self._force_send_key(vk, True) + + # ------------------------------------------------------------------ + # 向后兼容:和弦一次性播放(旧逻辑,现在 player 不再调用) + # ------------------------------------------------------------------ def execute_chord(self, midi_notes): - if not self.hwnd: + if self.controller is None and not self.hwnd: return normal_keys, shift_keys, ctrl_keys = [], [], [] diff --git a/agent/custom/action/auto_piano/midi_processor.py b/agent/custom/action/auto_piano/midi_processor.py index 30d79b32..e51d789a 100644 --- a/agent/custom/action/auto_piano/midi_processor.py +++ b/agent/custom/action/auto_piano/midi_processor.py @@ -6,42 +6,158 @@ class MidiProcessor: - def parse(self, file_path): + def parse(self, file_path: str, tracks: str | list[int] = "all") -> dict: ext = os.path.splitext(file_path)[1].lower() if ext in [".mid", ".midi"]: - return self._parse_midi_with_mido(file_path) + return self._parse_midi_with_mido(file_path, tracks) raise ValueError( f"Unsupported file format: {ext}. Only .mid and .midi are supported." ) - def _parse_midi_with_mido(self, file_path): + def _parse_midi_with_mido(self, file_path: str, tracks: str | list[int]) -> dict: mid = mido.MidiFile(file_path, clip=True) + + # 收集所有消息及其绝对 tick 位置和轨道索引 + all_events = [] + for idx, track in enumerate(mid.tracks): + abs_tick = 0 + for msg in track: + abs_tick += msg.time + all_events.append((abs_tick, idx, msg)) + + all_events.sort(key=lambda x: x[0]) + + # 确定要解析的轨道索引集合 + track_indices = set(self._resolve_track_indices(mid, tracks)) + + # 全局跟踪 tempo,并将 tick 正确转换为秒 + tempo = 500000 # 默认 120 BPM + last_tick = 0 + current_sec = 0.0 + active_notes: dict[tuple[int, int, int], float] = {} + pending_note_offs: set[tuple[int, int, int]] = set() + sustain_pedals: dict[tuple[int, int], bool] = {} notes = [] - current_time_sec = 0.0 - for msg in mid: - current_time_sec += msg.time + def close_note(key: tuple[int, int, int], end_sec: float) -> None: + start = active_notes.pop(key, None) + pending_note_offs.discard(key) + if start is None: + return + notes.append({ + "t": start, + "p": key[2], + "d": max(0.0, end_sec - start), + }) + + for abs_tick, idx, msg in all_events: + # tick -> second 转换 + if abs_tick > last_tick: + delta_tick = abs_tick - last_tick + current_sec += mido.tick2second(delta_tick, mid.ticks_per_beat, tempo) + last_tick = abs_tick + + if msg.type == "set_tempo": + tempo = msg.tempo + continue + + # 只处理选中的轨道 + if idx not in track_indices: + continue if msg.type == "note_on" and msg.velocity > 0: - if msg.channel == 9: + ch = getattr(msg, "channel", 0) + if ch == 9: continue + key = (idx, ch, msg.note) + # 如果同轨道同名音符已激活,先结束它(处理重叠) + if key in active_notes: + close_note(key, current_sec) + active_notes[key] = current_sec - notes.append( - { - "t": current_time_sec, - "p": msg.note, - } - ) + elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0): + ch = getattr(msg, "channel", 0) + if ch == 9: + continue + key = (idx, ch, msg.note) + if key not in active_notes: + continue + if sustain_pedals.get((idx, ch), False): + pending_note_offs.add(key) + else: + close_note(key, current_sec) - notes.sort(key=lambda item: item["t"]) + elif msg.type == "control_change" and msg.control == 64: + ch = getattr(msg, "channel", 0) + if ch == 9: + continue + pedal_key = (idx, ch) + is_down = msg.value >= 64 + was_down = sustain_pedals.get(pedal_key, False) + sustain_pedals[pedal_key] = is_down + if was_down and not is_down: + for key in list(pending_note_offs): + if key[0] == idx and key[1] == ch: + close_note(key, current_sec) + + # 处理文件末尾仍未关闭的音符 + for key, start in list(active_notes.items()): + notes.append({ + "t": start, + "p": key[2], + "d": max(0.05, current_sec - start), + }) + + notes.sort(key=lambda n: n["t"]) return { "title": os.path.basename(file_path), "author": "Unknown", - "bpm": 120, - "duration": mid.length, + "bpm": self._estimate_bpm(mid), + "duration": mid.length if mid.length else current_sec, "key": "Unknown", "notes": notes, + "track_count": len(mid.tracks), + "parsed_tracks": sorted(track_indices), } + + @staticmethod + def _resolve_track_indices(mid: mido.MidiFile, tracks: str | list[int]) -> list[int]: + if isinstance(tracks, list): + return sorted({idx for idx in tracks if 0 <= idx < len(mid.tracks)}) + + if tracks == "melody": + return [MidiProcessor._detect_melody_track(mid)] + + # 默认 "all" —— 所有轨道 + return list(range(len(mid.tracks))) + + @staticmethod + def _detect_melody_track(mid: mido.MidiFile) -> int: + """自动检测主旋律轨道:选择 note_on 事件最多且非打击乐的轨道。""" + best_idx = 0 + best_count = 0 + + for idx, track in enumerate(mid.tracks): + count = 0 + for msg in track: + if msg.type == "note_on" and msg.velocity > 0: + ch = getattr(msg, "channel", 0) + if ch != 9: + count += 1 + if count > best_count: + best_count = count + best_idx = idx + + return best_idx + + @staticmethod + def _estimate_bpm(mid: mido.MidiFile) -> int: + """从 meta 消息中尝试提取 BPM,默认返回 120。""" + for track in mid.tracks: + for msg in track: + if msg.type == "set_tempo": + return int(mido.tempo2bpm(msg.tempo)) + return 120 diff --git a/agent/custom/action/auto_piano/player.py b/agent/custom/action/auto_piano/player.py index fd8761bc..6ad77b8c 100644 --- a/agent/custom/action/auto_piano/player.py +++ b/agent/custom/action/auto_piano/player.py @@ -9,11 +9,14 @@ from utils.maafocus import PrintT from .maa_keyboard import MaaKeyboardBridge from .midi_processor import MidiProcessor +from . import key_mapping DEFAULT_SPEED = 1.0 DEFAULT_TRANSPOSE = 0 DEFAULT_COUNTDOWN = 1 -DEFAULT_CHORD_WINDOW = 0.005 + +# 定时精度阈值:大于此值用 sleep,小于此值忙等待(保证精度同时降低 CPU) +_WAIT_THRESHOLD = 0.003 class AutoPianoStopped(RuntimeError): @@ -25,6 +28,12 @@ class AutoPianoSettings: song: str speed: float = DEFAULT_SPEED transpose: int = DEFAULT_TRANSPOSE + key_mode: str = "36" + tracks: str = "all" # "all" | "melody" | 轨道索引列表由 action 处理 + out_of_range_mode: str = "fold" # "fold" | "shift" | "cut" + sustain: bool = True # True = 按 MIDI 时长延音; False = 短触键(调试模式) + sustain_mode: str = "repeat" # "repeat" = 高频补发; "hold" = 按住到音符结束 + sustain_repeat_rate: str = "fast" # slow | normal | fast | turbo class AutoPianoPlayer: @@ -42,29 +51,95 @@ def play(self, context: Context, settings: AutoPianoSettings) -> int: if not song_path.is_file(): raise FileNotFoundError(f"Song file not found: {song_path}") - parsed = self.processor.parse(str(song_path)) + # 解析轨道选择参数 + tracks_param = self._parse_tracks_param(settings.tracks) + + parsed = self.processor.parse(str(song_path), tracks=tracks_param) notes = parsed["notes"] if not notes: PrintT(context, "auto_piano.no_notes", str(song_path)) return 0 + # 根据超出音域模式处理音符 + notes, effective_transpose = self._apply_range_mode(notes, settings) + if not notes: + PrintT(context, "auto_piano.no_playable_notes") + return 0 + PrintT( context, "auto_piano.loaded", parsed["title"], len(notes), settings.speed, - settings.transpose, + effective_transpose, + settings.sustain, ) + if parsed.get("track_count", 1) > 1: + PrintT( + context, + "auto_piano.tracks_info", + parsed["track_count"], + parsed.get("parsed_tracks", []), + ) + + # 生成按键事件列表 + mapping = key_mapping.get_mapping(settings.key_mode) + events = self._build_events( + notes, mapping, settings.speed, effective_transpose, settings.key_mode, settings.out_of_range_mode, settings.sustain + ) + if not events: + PrintT(context, "auto_piano.no_playable_notes") + return 0 self.sleep_interruptibly(context, DEFAULT_COUNTDOWN) - bridge = MaaKeyboardBridge(self.get_controller(context)) - played = self.play_notes( - context, notes, bridge, settings.speed, settings.transpose + sustain_mode = ( + settings.sustain_mode if settings.sustain_mode in ("repeat", "hold") else "repeat" + ) + repeat_interval = self._parse_repeat_interval(settings.sustain_repeat_rate) + bridge = MaaKeyboardBridge( + self.get_controller(context), + wait_jobs=True, + mapping=mapping, + sustain_mode=sustain_mode, + repeat_interval=repeat_interval, ) + + try: + played = self._play_events(context, events, bridge, len(notes)) + finally: + # 无论成功失败,确保所有键都被释放 + bridge.release_all() + PrintT(context, "auto_piano.finished", played) return played + @staticmethod + def _parse_tracks_param(tracks_raw: str) -> str | list[int]: + """将 tracks 字符串参数转换为解析器需要的格式。""" + tracks_str = str(tracks_raw).strip().lower() + if tracks_str in ("all", "melody", ""): + return tracks_str or "all" + # 尝试解析为逗号分隔的轨道索引,例如 "0,2" + try: + indices = [int(x.strip()) for x in tracks_str.split(",") if x.strip()] + if indices: + return indices + except ValueError: + pass + return "all" + + @staticmethod + def _parse_repeat_interval(rate_raw: str) -> float: + rate = str(rate_raw).strip().lower() + rate_to_interval = { + "slow": 0.09, + "normal": 0.06, + "fast": 0.045, + "turbo": 0.03, + } + return rate_to_interval.get(rate, rate_to_interval["fast"]) + def resolve_song_path(self, song: str) -> Path: path = Path(song).expanduser() if path.is_absolute(): @@ -113,65 +188,185 @@ def sleep_interruptibly( return time.sleep(min(step, remaining)) - @staticmethod - def adjust_pitch(midi_pitch: int) -> int: + @classmethod + def _apply_range_mode( + cls, notes: list[dict], settings: AutoPianoSettings + ) -> tuple[list[dict], int]: + """ + 根据超出音域处理模式预处理音符。 + 返回: (处理后的音符列表, 有效移调值) + """ + mode = settings.out_of_range_mode + transpose = settings.transpose + + if mode == "fold": + return notes, transpose + + if mode == "shift": + pitches = [n["p"] for n in notes] + center = (min(pitches) + max(pitches)) / 2 + # 目标中心: 77.5 = (60 + 95) / 2 + auto_shift = round(77.5 - center) + effective_transpose = transpose + auto_shift + filtered = [] + for note in notes: + p = note["p"] + effective_transpose + if 60 <= p <= 95: + filtered.append({**note, "p": p}) + return filtered, effective_transpose + + if mode == "cut": + effective_transpose = transpose + filtered = [] + for note in notes: + p = note["p"] + effective_transpose + if 60 <= p <= 95: + filtered.append({**note, "p": p}) + return filtered, effective_transpose + + # 未知模式回退到 fold + return notes, transpose + + @classmethod + def adjust_pitch(cls, midi_pitch: int, key_mode: str = "36") -> int | None: + """调整音高到可用范围,并过滤掉无法映射的音符。""" + # 21键模式:先将半音映射到最近白键 + if key_mode == "21": + midi_pitch = key_mapping.snap_to_white_key(midi_pitch) + + # 折叠到游戏钢琴支持的 60~95 范围 while midi_pitch < 60: midi_pitch += 12 while midi_pitch > 95: midi_pitch -= 12 return midi_pitch + # ------------------------------------------------------------------ + # 事件生成 + # ------------------------------------------------------------------ + # 非延音模式下的固定触键时长(秒) + _STACCATO_DURATION = 0.05 + _MIN_NOTE_DURATION = 0.01 + @classmethod - def collect_chord( + def _build_events( cls, notes: list[dict], - start_index: int, + mapping: dict[int, str], + speed: float, transpose: int, - window: float, - ) -> tuple[list[int], int]: - base_time = notes[start_index]["t"] - chord = [] - index = start_index - - while index < len(notes) and abs(notes[index]["t"] - base_time) <= window: - pitch = int(notes[index]["p"]) + transpose - chord.append(cls.adjust_pitch(pitch)) - index += 1 - - return chord, index - + key_mode: str, + range_mode: str = "fold", + sustain: bool = True, + ) -> list[tuple[float, int, bool]]: + """ + 将音符列表转换为按键事件列表。 + 每个事件: (时间点, midi_pitch, is_press) + """ + events = [] + for note in notes: + raw_pitch = int(note["p"]) + if range_mode == "fold": + raw_pitch += transpose + pitch = cls.adjust_pitch(raw_pitch, key_mode) + else: + # shift / cut 模式下,音符已在 _apply_range_mode 中处理并过滤 + pitch = raw_pitch + if key_mode == "21": + pitch = key_mapping.snap_to_white_key(pitch) + + if pitch is None or pitch not in mapping: + continue + + start = float(note["t"]) / speed + if sustain: + duration = max(float(note.get("d", 0.05)) / speed, cls._MIN_NOTE_DURATION) + else: + # 非延音模式:固定短触键,方便调试 + duration = max(cls._STACCATO_DURATION / speed, cls._MIN_NOTE_DURATION) + end = start + duration + + events.append((start, pitch, True)) # press + events.append((end, pitch, False)) # release + + # 排序:时间升序;同一时刻,release (False=0) 排在 press (True=1) 前面 + events.sort(key=lambda e: (e[0], e[2])) + return events + + # ------------------------------------------------------------------ + # 事件播放(核心) + # ------------------------------------------------------------------ @classmethod - def play_notes( + def _play_events( cls, context: Context, - notes: list[dict], + events: list[tuple[float, int, bool]], bridge: MaaKeyboardBridge, - speed: float, - transpose: int, + total_notes: int, ) -> int: start_time = time.perf_counter() - elapsed = 0.0 - index = 0 played = 0 + report_interval = max(1, total_notes // 10) + next_report = report_interval - while index < len(notes): + for evt_time, pitch, is_press in events: cls.raise_if_stopped(context) - target_time = float(notes[index]["t"]) / speed - chord, next_index = cls.collect_chord( - notes, index, transpose, DEFAULT_CHORD_WINDOW - ) + cls._wait_until(context, start_time + evt_time, bridge) + + if is_press: + bridge.press_note(pitch) + played += 1 + if played >= next_report: + PrintT( + context, + "auto_piano.progress", + played, + total_notes, + int(played * 100 / total_notes), + ) + next_report += report_interval + else: + bridge.release_note(pitch) - while elapsed < target_time: - cls.raise_if_stopped(context) - elapsed = time.perf_counter() - start_time - remaining = target_time - elapsed - if remaining > 0: - time.sleep(min(0.002, remaining)) + return played + # ------------------------------------------------------------------ + # 低 CPU 高精度等待 + # ------------------------------------------------------------------ + @classmethod + def _wait_until( + cls, context: Context, deadline: float, bridge: MaaKeyboardBridge | None = None + ) -> None: + """ + 混合等待策略: + - 剩余时间较大时,用 time.sleep() 让出 CPU; + - 长等待分段睡,每 0.1 秒检查一次停止信号; + - 接近目标时,小范围忙等待保证按键时序精度。 + """ + while True: cls.raise_if_stopped(context) - bridge.execute_chord(chord) - elapsed = time.perf_counter() - start_time - played += len(chord) - index = next_index + if bridge is not None: + bridge.refresh_active_keys() + now = time.perf_counter() + remaining = deadline - now + if remaining <= 0: + if bridge is not None: + bridge.refresh_active_keys(force=True) + return - return played + if bridge is not None and getattr(bridge, "_active_counts", None): + if getattr(bridge, "sustain_mode", "hold") == "repeat": + active_step = min(max(bridge.repeat_interval / 2, 0.005), 0.02) + else: + active_step = 0.05 + time.sleep(min(active_step, remaining)) + elif remaining > 0.5: + # 长等待分段睡,防止 sleep 期间无法响应停止 + time.sleep(0.1) + elif remaining > _WAIT_THRESHOLD: + # 提前 _WAIT_THRESHOLD 秒唤醒,再进入忙等待收尾 + time.sleep(remaining - _WAIT_THRESHOLD) + elif remaining > 0.0003: + # 最后 0.3ms 用更细粒度 sleep(避免完全忙等待吃满 CPU) + time.sleep(0.0002) + # 最后几百微秒自然循环收尾,保证精度 diff --git a/assets/resource/locales/interface/en_us.json b/assets/resource/locales/interface/en_us.json index 23310ea6..3eabea3a 100644 --- a/assets/resource/locales/interface/en_us.json +++ b/assets/resource/locales/interface/en_us.json @@ -144,6 +144,18 @@ "task_auto_piano_input_transpose_label": "Transpose", "task_auto_piano_input_speed_pattern_msg": "Please enter a number greater than 0, e.g. 1.0 or 1.25.", "task_auto_piano_input_transpose_pattern_msg": "Please enter an integer number of semitones, e.g. -12, 0, 7.", + "task_auto_piano_input_range_mode_label": "Out-of-Range Mode", + "task_auto_piano_input_range_mode_desc": "fold = octave fold (default, keeps all notes but may collide); shift = auto-transpose + cut (correct intervals, recommended for multi-track); cut = direct cut (keeps only 60~95 range).", + "task_auto_piano_input_range_mode_pattern_msg": "Please enter fold, shift, or cut.", + "task_auto_piano_input_sustain_label": "Sustain", + "task_auto_piano_input_sustain_desc": "true = sustain on (hold notes by MIDI duration); false = staccato (short fixed duration, debug mode).", + "task_auto_piano_input_sustain_pattern_msg": "Please enter true or false.", + "task_auto_piano_input_sustain_mode_label": "Sustain mode", + "task_auto_piano_input_sustain_mode_desc": "repeat = repeatedly send key-down while sustained; hold = press once and release when the note ends.", + "task_auto_piano_input_sustain_mode_pattern_msg": "Please enter repeat or hold.", + "task_auto_piano_input_sustain_repeat_rate_label": "Repeat rate", + "task_auto_piano_input_sustain_repeat_rate_desc": "Used by repeat mode: slow=90ms, normal=60ms, fast=45ms, turbo=30ms. Faster feels more like repeated taps and sends more messages.", + "task_auto_piano_input_sustain_repeat_rate_pattern_msg": "Please enter slow, normal, fast, or turbo.", "task_auto_drive_dataset_recorder_label": "Autonomous Driving Dataset Collection", "task_auto_drive_dataset_recorder_desc": "Before starting this task, make sure the minimap destination is set. The default recording duration is 60 seconds, with 2 samples per second.", "task_auto_drive_dataset_option_settings": "Recording Settings", diff --git a/assets/resource/locales/interface/ja_jp.json b/assets/resource/locales/interface/ja_jp.json index 35732ddf..ae7e043e 100644 --- a/assets/resource/locales/interface/ja_jp.json +++ b/assets/resource/locales/interface/ja_jp.json @@ -144,6 +144,18 @@ "task_auto_piano_input_transpose_label": "移調", "task_auto_piano_input_speed_pattern_msg": "0より大きい数値を入力してください。例:1.0、1.25。", "task_auto_piano_input_transpose_pattern_msg": "半音数を整数で入力してください。例:-12、0、7。", + "task_auto_piano_input_range_mode_label": "音域外処理", + "task_auto_piano_input_range_mode_desc": "fold = オクターブ折り返し(デフォルト、全音符を保持しますが衝突の可能性あり);shift = 自動移調+カット(音程関係が正確、マルチトラックに推奨);cut = 直接カット(60~95範囲のみ保持)。", + "task_auto_piano_input_range_mode_pattern_msg": "fold、shift、cut のいずれかを入力してください。", + "task_auto_piano_input_sustain_label": "サステイン", + "task_auto_piano_input_sustain_desc": "true = サステインオン(MIDIの長さで音符を保持);false = スタッカート(固定短時間、デバッグモード)。", + "task_auto_piano_input_sustain_pattern_msg": "true または false を入力してください。", + "task_auto_piano_input_sustain_mode_label": "サステインモード", + "task_auto_piano_input_sustain_mode_desc": "repeat = サステイン中にキー押下を高頻度で再送信;hold = 一度だけ押して音符終了まで保持。", + "task_auto_piano_input_sustain_mode_pattern_msg": "repeat または hold を入力してください。", + "task_auto_piano_input_sustain_repeat_rate_label": "再送信頻度", + "task_auto_piano_input_sustain_repeat_rate_desc": "repeat モード用:slow=90ms、normal=60ms、fast=45ms、turbo=30ms。速いほど連打に近くなり、送信量も増えます。", + "task_auto_piano_input_sustain_repeat_rate_pattern_msg": "slow、normal、fast、turbo のいずれかを入力してください。", "task_auto_drive_dataset_recorder_label": "自動運転データセット収集", "task_auto_drive_dataset_recorder_desc": "このタスクを開始する前に、ミニマップの目的地が設定されていることを確認してください。デフォルトの録画時間は 60 秒で、1 秒あたり 2 回サンプリングします。", "task_auto_drive_dataset_option_settings": "録画設定", diff --git a/assets/resource/locales/interface/ko_kr.json b/assets/resource/locales/interface/ko_kr.json index 8539f715..a952a7e9 100644 --- a/assets/resource/locales/interface/ko_kr.json +++ b/assets/resource/locales/interface/ko_kr.json @@ -144,6 +144,18 @@ "task_auto_piano_input_transpose_label": "조옮김", "task_auto_piano_input_speed_pattern_msg": "0보다 큰 숫자를 입력하세요. 예: 1.0, 1.25.", "task_auto_piano_input_transpose_pattern_msg": "반음 수를 정수로 입력하세요. 예: -12, 0, 7.", + "task_auto_piano_input_range_mode_label": "음역 외 처리", + "task_auto_piano_input_range_mode_desc": "fold = 옥타브 접기 (기본값, 모든 음을 유지하지만 충돌 가능);shift = 자동 조옮김+절단 (정확한 음정 관계, 다중 트랙에 권장);cut = 직접 절단 (60~95 범위만 유지).", + "task_auto_piano_input_range_mode_pattern_msg": "fold, shift 또는 cut을 입력하세요.", + "task_auto_piano_input_sustain_label": "서스테인", + "task_auto_piano_input_sustain_desc": "true = 서스테인 켜기(MIDI 길이로 음 유지);false = 스타카토(고정 짧은 시간, 디버그 모드)。", + "task_auto_piano_input_sustain_pattern_msg": "true 또는 false를 입력하세요.", + "task_auto_piano_input_sustain_mode_label": "서스테인 모드", + "task_auto_piano_input_sustain_mode_desc": "repeat = 유지 중 키다운을 고주파로 반복 전송; hold = 한 번 누르고 음이 끝날 때 놓기.", + "task_auto_piano_input_sustain_mode_pattern_msg": "repeat 또는 hold를 입력하세요.", + "task_auto_piano_input_sustain_repeat_rate_label": "반복 전송 빈도", + "task_auto_piano_input_sustain_repeat_rate_desc": "repeat 모드에서 사용: slow=90ms, normal=60ms, fast=45ms, turbo=30ms. 빠를수록 연타에 가깝고 메시지도 많아집니다.", + "task_auto_piano_input_sustain_repeat_rate_pattern_msg": "slow, normal, fast 또는 turbo를 입력하세요.", "task_auto_drive_dataset_recorder_label": "자율 주행 데이터세트 수집", "task_auto_drive_dataset_recorder_desc": "이 작업을 시작하기 전에 미니맵 목적지가 설정되어 있는지 확인하세요. 기본 녹화 시간은 60초이며 초당 2회 샘플링합니다.", "task_auto_drive_dataset_option_settings": "녹화 설정", diff --git a/assets/resource/locales/interface/zh_cn.json b/assets/resource/locales/interface/zh_cn.json index 4dc9313a..c1e2ad7a 100644 --- a/assets/resource/locales/interface/zh_cn.json +++ b/assets/resource/locales/interface/zh_cn.json @@ -144,6 +144,24 @@ "task_auto_piano_input_transpose_label": "转调", "task_auto_piano_input_speed_pattern_msg": "请输入大于 0 的数字,例如 1.0 或 1.25。", "task_auto_piano_input_transpose_pattern_msg": "请输入整数半音数,例如 -12、0、7。", + "task_auto_piano_input_key_mode_label": "键位模式", + "task_auto_piano_input_key_mode_desc": "36 = 完整36键(含半音,推荐);21 = 仅白键21键(简单模式,半音会被映射到最近白键)。", + "task_auto_piano_input_key_mode_pattern_msg": "请输入 36 或 21。", + "task_auto_piano_input_tracks_label": "解析轨道", + "task_auto_piano_input_tracks_desc": "all = 合并所有轨道;melody = 自动选择主旋律轨道(音符最多的非打击乐轨道);也可以填逗号分隔的轨道索引,例如 0,2", + "task_auto_piano_input_tracks_pattern_msg": "请输入 all、melody 或逗号分隔的轨道索引(如 0,2)。", + "task_auto_piano_input_range_mode_label": "超域处理", + "task_auto_piano_input_range_mode_desc": "fold = 八度折叠(默认,所有音符保留但可能碰撞);shift = 自动移调+截断(音程关系正确,推荐用于多声部);cut = 直接截断(只保留 60~95 范围内的音符)。", + "task_auto_piano_input_range_mode_pattern_msg": "请输入 fold、shift 或 cut。", + "task_auto_piano_input_sustain_label": "延音", + "task_auto_piano_input_sustain_desc": "true = 延音开启(按 MIDI 时长保持音符);false = 短触键(固定短时长,调试模式)。", + "task_auto_piano_input_sustain_pattern_msg": "请输入 true 或 false。", + "task_auto_piano_input_sustain_mode_label": "延音模式", + "task_auto_piano_input_sustain_mode_desc": "repeat = 高频补发按下,适合游戏不认长按时使用;hold = 只按下一次并保持到音符结束。", + "task_auto_piano_input_sustain_mode_pattern_msg": "请输入 repeat 或 hold。", + "task_auto_piano_input_sustain_repeat_rate_label": "补发频率", + "task_auto_piano_input_sustain_repeat_rate_desc": "repeat 模式使用:slow=90ms,normal=60ms,fast=45ms,turbo=30ms。越快越像连点,也越吃消息。", + "task_auto_piano_input_sustain_repeat_rate_pattern_msg": "请输入 slow、normal、fast 或 turbo。", "task_auto_drive_dataset_recorder_label": "自动驾驶数据集收集", "task_auto_drive_dataset_recorder_desc": "开始此任务前确保小地图已设置好目的地,默认录制时长为 60 秒,每秒采样 2 次。", "task_auto_drive_dataset_option_settings": "录制设置", diff --git a/assets/resource/locales/interface/zh_tw.json b/assets/resource/locales/interface/zh_tw.json index 4e9fb218..339d9618 100644 --- a/assets/resource/locales/interface/zh_tw.json +++ b/assets/resource/locales/interface/zh_tw.json @@ -144,6 +144,18 @@ "task_auto_piano_input_transpose_label": "轉調", "task_auto_piano_input_speed_pattern_msg": "請輸入大於 0 的數字,例如 1.0 或 1.25。", "task_auto_piano_input_transpose_pattern_msg": "請輸入整數半音數,例如 -12、0、7。", + "task_auto_piano_input_range_mode_label": "超域處理", + "task_auto_piano_input_range_mode_desc": "fold = 八度摺疊(預設,所有音符保留但可能碰撞);shift = 自動移調+截斷(音程關係正確,推薦用於多聲部);cut = 直接截斷(只保留 60~95 範圍內的音符)。", + "task_auto_piano_input_range_mode_pattern_msg": "請輸入 fold、shift 或 cut。", + "task_auto_piano_input_sustain_label": "延音", + "task_auto_piano_input_sustain_desc": "true = 延音開啟(按 MIDI 時長保持音符);false = 短觸鍵(固定短時長,調試模式)。", + "task_auto_piano_input_sustain_pattern_msg": "請輸入 true 或 false。", + "task_auto_piano_input_sustain_mode_label": "延音模式", + "task_auto_piano_input_sustain_mode_desc": "repeat = 高頻補發按下,適合遊戲不認長按時使用;hold = 只按下一次並保持到音符結束。", + "task_auto_piano_input_sustain_mode_pattern_msg": "請輸入 repeat 或 hold。", + "task_auto_piano_input_sustain_repeat_rate_label": "補發頻率", + "task_auto_piano_input_sustain_repeat_rate_desc": "repeat 模式使用:slow=90ms,normal=60ms,fast=45ms,turbo=30ms。越快越像連點,也越吃訊息。", + "task_auto_piano_input_sustain_repeat_rate_pattern_msg": "請輸入 slow、normal、fast 或 turbo。", "task_auto_drive_dataset_recorder_label": "自動駕駛資料集收集", "task_auto_drive_dataset_recorder_desc": "開始此任務前,請確保小地圖已設定好目的地,預設錄製時長為 60 秒,每秒取樣 2 次。", "task_auto_drive_dataset_option_settings": "錄製設定", diff --git a/assets/resource/tasks/AutoPiano.json b/assets/resource/tasks/AutoPiano.json index 14460c78..b817bd12 100644 --- a/assets/resource/tasks/AutoPiano.json +++ b/assets/resource/tasks/AutoPiano.json @@ -54,6 +54,60 @@ "pipeline_type": "string", "verify": "^-?\\d+$", "pattern_msg": "$task_auto_piano_input_transpose_pattern_msg" + }, + { + "name": "KeyMode", + "label": "$task_auto_piano_input_key_mode_label", + "description": "$task_auto_piano_input_key_mode_desc", + "default": "36", + "pipeline_type": "string", + "verify": "^(36|21)$", + "pattern_msg": "$task_auto_piano_input_key_mode_pattern_msg" + }, + { + "name": "Tracks", + "label": "$task_auto_piano_input_tracks_label", + "description": "$task_auto_piano_input_tracks_desc", + "default": "all", + "pipeline_type": "string", + "verify": "^(all|melody|\\d+(,\\d+)*)$", + "pattern_msg": "$task_auto_piano_input_tracks_pattern_msg" + }, + { + "name": "OutOfRangeMode", + "label": "$task_auto_piano_input_range_mode_label", + "description": "$task_auto_piano_input_range_mode_desc", + "default": "fold", + "pipeline_type": "string", + "verify": "^(fold|shift|cut)$", + "pattern_msg": "$task_auto_piano_input_range_mode_pattern_msg" + }, + { + "name": "Sustain", + "label": "$task_auto_piano_input_sustain_label", + "description": "$task_auto_piano_input_sustain_desc", + "default": "true", + "pipeline_type": "string", + "verify": "^(true|false)$", + "pattern_msg": "$task_auto_piano_input_sustain_pattern_msg" + }, + { + "name": "SustainMode", + "label": "$task_auto_piano_input_sustain_mode_label", + "description": "$task_auto_piano_input_sustain_mode_desc", + "default": "repeat", + "pipeline_type": "string", + "verify": "^(repeat|hold)$", + "pattern_msg": "$task_auto_piano_input_sustain_mode_pattern_msg" + }, + { + "name": "SustainRepeatRate", + "label": "$task_auto_piano_input_sustain_repeat_rate_label", + "description": "$task_auto_piano_input_sustain_repeat_rate_desc", + "default": "fast", + "pipeline_type": "string", + "verify": "^(slow|normal|fast|turbo)$", + "pattern_msg": "$task_auto_piano_input_sustain_repeat_rate_pattern_msg" } ], "pipeline_override": { @@ -61,7 +115,13 @@ "custom_action_param": { "song": "{SongPath}", "speed": "{Speed}", - "transpose": "{Transpose}" + "transpose": "{Transpose}", + "key_mode": "{KeyMode}", + "tracks": "{Tracks}", + "out_of_range_mode": "{OutOfRangeMode}", + "sustain": "{Sustain}", + "sustain_mode": "{SustainMode}", + "sustain_repeat_rate": "{SustainRepeatRate}" } } }