260 lines
7.6 KiB
Python
260 lines
7.6 KiB
Python
#!/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()
|