Files
PianoVisualizer-Python/midi_piano_viz.py
2026-04-21 10:57:05 +00:00

260 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()