From c389659ac99cddb977eee6bd51bdf5ba3e9762e4 Mon Sep 17 00:00:00 2001 From: Wiwi Kuan Date: Tue, 21 Apr 2026 10:57:05 +0000 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E5=82=B3=E6=AA=94=E6=A1=88=E5=88=B0?= =?UTF-8?q?=E3=80=8C/=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- midi_piano_viz.py | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 midi_piano_viz.py diff --git a/midi_piano_viz.py b/midi_piano_viz.py new file mode 100644 index 0000000..9810219 --- /dev/null +++ b/midi_piano_viz.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +MIDI Piano Visualizer (AA version) +Supersampling anti-aliasing for smooth rounded keys. + +Dependencies: + pip install pygame mido python-rtmidi + +Run: + python midi_piano_viz.py +""" + +import sys +import colorsys +import pygame +import mido + +# ---------- 可調參數(對齊原版) ---------- + +IS_BLACK = [0, 11, 0, 13, 0, 0, 11, 0, 12, 0, 13, 0] + +BORDER = 3 +WHITE_KEY_WIDTH = 20 +WHITE_KEY_SPACE = 1 +BLACK_KEY_WIDTH = 17 +BLACK_KEY_HEIGHT = 45 +RADIUS = 5 +B_RADIUS = 4 +KEY_AREA_Y = 3 +KEY_AREA_HEIGHT = 70 + +RAINBOW_MODE = False +NOTE_NAMES = False + +KEY_ON_HSB = (326, 100, 100) +PEDALED_HSB = (326, 100, 70) + +WIN_WIDTH = (WHITE_KEY_WIDTH + WHITE_KEY_SPACE) * 52 + (BORDER * 2) +WIN_HEIGHT = KEY_AREA_HEIGHT + (KEY_AREA_Y * 2) + +BG_HSB = (0, 0, 30) +FPS = 60 + +# 超取樣倍率:3x 已經很漂亮,4x 更絲滑但稍微吃 CPU +SUPERSAMPLE = 3 + +# ---------- 工具 ---------- + +def hsb_to_rgb(h, s, v): + r, g, b = colorsys.hsv_to_rgb(h / 360.0, s / 100.0, v / 100.0) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def rainbow_color(pitch, value=100): + hue = ((pitch - 21) / (108 - 21) * 1080) % 360 + return hsb_to_rgb(hue, 100, value) + + +def pick_midi_input(): + ports = mido.get_input_names() + if not ports: + print("找不到任何 MIDI 輸入裝置。請先插上鍵盤再試一次。") + sys.exit(1) + + print("\nAvailable MIDI inputs:") + for i, name in enumerate(ports): + print(f" [{i}] {name}") + + if len(ports) == 1: + print(f"\n只有一個裝置,自動選擇 [0] {ports[0]}") + return ports[0] + + while True: + try: + choice = input("\n選擇要使用的 MIDI 輸入編號:").strip() + idx = int(choice) + if 0 <= idx < len(ports): + return ports[idx] + except (ValueError, KeyboardInterrupt, EOFError): + print("再來一次。") + + +# ---------- 狀態 ---------- + +class State: + def __init__(self): + self.is_key_on = [0] * 128 + self.is_pedaled = [0] * 128 + self.now_pedaling = False + + def note_on(self, pitch, velocity): + if velocity == 0: + self.note_off(pitch) + return + self.is_key_on[pitch] = 1 + if self.now_pedaling: + self.is_pedaled[pitch] = 1 + + def note_off(self, pitch): + self.is_key_on[pitch] = 0 + + def control_change(self, number, value): + if number == 64: + if value >= 64: + self.now_pedaling = True + for i in range(128): + self.is_pedaled[i] = self.is_key_on[i] + else: + self.now_pedaling = False + for i in range(128): + self.is_pedaled[i] = 0 + + +# ---------- 繪製(在放大的 surface 上畫,最後縮小) ---------- + +def draw_rounded_rect(surface, color, rect, radius, stroke_color=None, stroke_width=0): + pygame.draw.rect(surface, color, rect, border_radius=radius) + if stroke_color is not None and stroke_width > 0: + pygame.draw.rect(surface, stroke_color, rect, width=stroke_width, border_radius=radius) + + +def draw_scene(big_surface, state, font_big): + s = SUPERSAMPLE + + border = BORDER * s + wkw = WHITE_KEY_WIDTH * s + wks = WHITE_KEY_SPACE * s + bkw = BLACK_KEY_WIDTH * s + bkh = BLACK_KEY_HEIGHT * s + radius = RADIUS * s + bradius = B_RADIUS * s + kay = KEY_AREA_Y * s + kah = KEY_AREA_HEIGHT * s + + key_on_rgb = hsb_to_rgb(*KEY_ON_HSB) + pedaled_rgb = hsb_to_rgb(*PEDALED_HSB) + white_rgb = (255, 255, 255) + black_rgb = (0, 0, 0) + stroke_rgb = (0, 0, 0) + bg_rgb = hsb_to_rgb(*BG_HSB) + + big_surface.fill(bg_rgb) + + # --- 白鍵 --- + w_index = 0 + for i in range(21, 109): + if IS_BLACK[i % 12] == 0: + if state.is_key_on[i] == 1 and not RAINBOW_MODE: + color = key_on_rgb + elif state.is_key_on[i] == 1 and RAINBOW_MODE: + color = rainbow_color(i, 100) + elif state.is_pedaled[i] == 1 and not RAINBOW_MODE: + color = pedaled_rgb + elif state.is_pedaled[i] == 1 and RAINBOW_MODE: + color = rainbow_color(i, 70) + else: + color = white_rgb + + x = border + w_index * (wkw + wks) + rect = pygame.Rect(x, kay, wkw, kah) + draw_rounded_rect(big_surface, color, rect, radius, + stroke_color=stroke_rgb, stroke_width=max(1, s // 2)) + w_index += 1 + + # --- 黑鍵 --- + w_index = 0 + for i in range(21, 109): + if IS_BLACK[i % 12] == 0: + w_index += 1 + continue + + if state.is_key_on[i] == 1 and not RAINBOW_MODE: + color = key_on_rgb + elif state.is_key_on[i] == 1 and RAINBOW_MODE: + color = rainbow_color(i, 100) + elif state.is_pedaled[i] == 1 and not RAINBOW_MODE: + color = pedaled_rgb + elif state.is_pedaled[i] == 1 and RAINBOW_MODE: + color = rainbow_color(i, 70) + else: + color = black_rgb + + x = border + (w_index - 1) * (wkw + wks) + IS_BLACK[i % 12] * s + rect = pygame.Rect(x, kay - 1 * s, bkw, bkh) + draw_rounded_rect(big_surface, color, rect, bradius, + stroke_color=stroke_rgb, stroke_width=max(1, int(s * 0.75))) + + # --- 音名 --- + if NOTE_NAMES: + note_names = ["A", "B", "C", "D", "E", "F", "G"] + text_rgb = (64, 64, 64) + w_index = 0 + for i in range(52): + x = border + w_index * (wkw + wks) + y = kay + kah - 13 * s + name = note_names[i % 7] + surf = font_big.render(name, True, text_rgb) + rect = surf.get_rect(center=(x + wkw // 2, y)) + big_surface.blit(surf, rect) + w_index += 1 + + +# ---------- 主程式 ---------- + +def main(): + port_name = pick_midi_input() + + pygame.init() + pygame.display.set_caption("MIDI Piano Visualizer") + + screen = pygame.display.set_mode((WIN_WIDTH, WIN_HEIGHT)) + big_surface = pygame.Surface((WIN_WIDTH * SUPERSAMPLE, WIN_HEIGHT * SUPERSAMPLE)) + clock = pygame.time.Clock() + + # 字型也要放大版(跟著 big_surface 一起縮小,文字就會 AA) + font_big = pygame.font.SysFont(None, 14 * SUPERSAMPLE) + + state = State() + + print(f"\n開始接收:{port_name}") + print("視窗聚焦時按 R 切換彩虹模式、N 切換音名、ESC 或關閉視窗結束。") + + global RAINBOW_MODE, NOTE_NAMES + + with mido.open_input(port_name) as port: + running = True + while running: + for msg in port.iter_pending(): + if msg.type == "note_on": + state.note_on(msg.note, msg.velocity) + elif msg.type == "note_off": + state.note_off(msg.note) + elif msg.type == "control_change": + state.control_change(msg.control, msg.value) + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + elif event.key == pygame.K_r: + RAINBOW_MODE = not RAINBOW_MODE + elif event.key == pygame.K_n: + NOTE_NAMES = not NOTE_NAMES + + # 1) 在放大 surface 上繪製 + draw_scene(big_surface, state, font_big) + # 2) smoothscale 縮回視窗大小 → 邊緣自動柔化 + pygame.transform.smoothscale(big_surface, (WIN_WIDTH, WIN_HEIGHT), screen) + + pygame.display.flip() + clock.tick(FPS) + + pygame.quit() + + +if __name__ == "__main__": + main()