上傳檔案到「/」
This commit is contained in:
259
midi_piano_viz.py
Normal file
259
midi_piano_viz.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user