음악 파티클 비주얼라이저
설명
음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화
필요 데이터
음악 파일 1개 (WAV/MP3/FLAC/OGG)
출력
실시간 파티클 애니메이션 + 비트 감지 요약 표
필요 패키지
- numpy
- matplotlib
- miniaudio
태그
creative, visualization, realtime
작성자
MDS 메이커 | MDS 1.0
음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화
Real-time particle visualizer that pulses rings on every beat
🔍 클릭하면 원본 크기로 보기README
음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화
음악 파일 1개 (WAV/MP3/FLAC/OGG)
실시간 파티클 애니메이션 + 비트 감지 요약 표
creative, visualization, realtime
MDS 메이커 | MDS 1.0
# MDS_TITLE: 음악 파티클 비주얼라이저
# MDS_REQUIRES: numpy, matplotlib, miniaudio
# MDS_DESC: 음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화
# MDS_DESC[en]: Real-time particle visualizer that pulses rings on every beat
# MDS_TAGS: creative, visualization, realtime
# MDS_INPUT: 음악 파일 1개 (WAV/MP3/FLAC/OGG)
# MDS_OUTPUT: 실시간 파티클 애니메이션 + 비트 감지 요약 표
# MDS_AUTHOR: MDS 메이커
# MDS_VERSION: 1.0
# MDS_DATE: 2026-09-14
#
import os
def choose_audio_file() -> str:
path = mIO.open_file_single(
dialog_title="음악 파일 선택",
file_filter="오디오 파일 (*.wav *.mp3 *.flac *.ogg)",
initial_dir=None,
)
return path or ""
def ask_particle_options() -> dict:
values = mIO.form([
{"type": "spin", "label": "재생 길이(초)", "min": 5, "max": 120, "default": 20, "step": 5},
{"type": "spin", "label": "원 수명(ms)", "min": 200, "max": 3000, "default": 800, "step": 100},
{"type": "spin", "label": "프레임당 최대 원 수", "min": 1, "max": 10, "default": 3, "step": 1},
{"type": "spin", "label": "감도(진폭 임계 %)", "min": 0, "max": 100, "default": 20, "step": 5},
{"type": "spin", "label": "비트 감도(%)", "min": 110, "max": 300, "default": 150, "step": 10},
{"type": "spin", "label": "갱신 간격(ms)", "min": 20, "max": 200, "default": 50, "step": 10},
], title="음악 파티클 시각화 설정")
return values or {}
def estimate_peak(audio: object) -> float:
# 앞 1초 구간만 훑어 진폭 정규화 기준을 구한다 — 전체 샘플을 복사하지 않는다
nch = int(audio.nchannels)
head = audio.samples[0: int(audio.sample_rate) * nch: nch]
peak = 0.0
for v in head:
av = abs(float(v))
if av > peak:
peak = av
return peak if peak > 0 else 1.0
def compute_frame_features(window: np.ndarray, sample_rate: int, peak: float) -> tuple[float, float]:
if window.size < 8:
return 0.0, 0.5
rms = float(np.sqrt(np.mean(window ** 2)))
strength = min(1.0, rms / peak) if peak > 0 else 0.0
spectrum = np.abs(np.fft.rfft(window * np.hanning(window.size)))
freqs = np.fft.rfftfreq(window.size, d=1.0 / sample_rate)
total = float(spectrum.sum())
centroid = float((spectrum * freqs).sum() / total) if total > 0 else 20.0
lo, hi = 20.0, 10000.0
centroid = min(max(centroid, lo), hi)
tone = float((np.log10(centroid) - np.log10(lo)) / (np.log10(hi) - np.log10(lo)))
return strength, tone
def detect_beat(energy: float, history: list[float], frame_index: int,
last_beat_frame: int, interval_ms: int, beat_ratio: float) -> bool:
# 최근 프레임 평균 에너지 대비 급증이면 비트로 본다(불응기 150ms)
if len(history) < 8:
return False
refractory_frames = max(1, int(150 / max(10, interval_ms)))
if frame_index - last_beat_frame < refractory_frames:
return False
mean_energy = float(np.mean(history))
if mean_energy <= 0.0:
return False
return energy > mean_energy * beat_ratio
def spawn_particles(strength: float, tone: float, max_per_frame: int, is_beat: bool) -> list[dict]:
n_new = max(1, int(round(strength * max_per_frame)))
if is_beat:
n_new = min(30, n_new * 3)
cmap = plt.get_cmap("turbo")
rgb = cmap(tone)[:3]
new_particles = []
for _ in range(n_new):
new_particles.append({
"x": float(np.random.rand()),
"y": float(np.random.rand()),
"base_size": 120.0 + 900.0 * strength, # scatter 면적(pt^2) 기준 초기값
"rgb": rgb,
"age": 0,
})
return new_particles
def spawn_beat_ring(tone: float) -> dict:
cmap = plt.get_cmap("turbo")
return {
"x": 0.5,
"y": 0.5,
"base_size": 2400.0,
"rgb": cmap(tone)[:3],
"age": 0,
}
def advance_particles(particles: list[dict], life_frames: int, max_alive: int) -> list[dict]:
alive = []
for p in particles:
p["age"] += 1
if p["age"] < life_frames:
alive.append(p)
if len(alive) > max_alive:
alive = alive[-max_alive:]
return alive
def build_scatter_arrays(particles: list[dict], life_frames: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
n = len(particles)
if n == 0:
return np.empty((0, 2)), np.empty((0,)), np.empty((0, 4))
offsets = np.empty((n, 2), dtype="float32")
sizes = np.empty(n, dtype="float32")
colors = np.empty((n, 4), dtype="float32")
for i, p in enumerate(particles):
progress = p["age"] / life_frames
offsets[i, 0] = p["x"]
offsets[i, 1] = p["y"]
# 반경이 최대 3배까지 커지므로 면적은 (1 + 2*progress)^2 배
sizes[i] = p["base_size"] * (1.0 + 2.0 * progress) ** 2
colors[i, 0:3] = p["rgb"]
colors[i, 3] = max(0.0, 1.0 - progress)
return offsets, sizes, colors
def visualize_particles(path: str, play_sec: int, life_ms: int, max_per_frame: int,
sensitivity: int, beat_sensitivity: int, interval_ms: int) -> dict:
audio = mAudio.load(path, duration_sec=float(play_sec))
sample_rate = int(audio.sample_rate)
nch = int(audio.nchannels)
peak = estimate_peak(audio)
fft_points = 2048
window_frames = max(fft_points, int(sample_rate * 0.05))
life_frames = max(2, int(life_ms / max(10, interval_ms)))
ring_life_frames = max(3, int(life_frames * 1.5))
threshold = sensitivity / 100.0
beat_ratio = beat_sensitivity / 100.0
max_alive = 300
shared: dict = {
"particles": [], "rings": [], "scatter": None, "ring_scatter": None,
"energy_history": [], "last_beat_frame": -999, "spawned": 0, "beats": 0,
}
def setup(fig, ax) -> dict:
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False
fig.patch.set_facecolor("#000000")
ax.set_facecolor("#000000")
ax.set_xlim(0.0, 1.0)
ax.set_ylim(0.0, 1.0)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
shared["scatter"] = ax.scatter(
np.empty((0,)), np.empty((0,)),
s=np.empty((0,)), facecolors="none", linewidths=1.6,
)
shared["ring_scatter"] = ax.scatter(
np.empty((0,)), np.empty((0,)),
s=np.empty((0,)), facecolors="none", linewidths=3.0,
)
return shared
def start(state: dict) -> None:
mAudio.play(audio)
def update(elapsed_sec: float, frame_index: int, state: dict, ax) -> bool:
pos_frames = int(float(mAudio.get_position()) * sample_rate)
start_frame = max(0, pos_frames - window_frames)
raw = audio.samples[start_frame * nch: pos_frames * nch: nch]
window = np.asarray(raw, dtype="float32")
if window.size > fft_points:
window = window[-fft_points:]
strength, tone = compute_frame_features(window, sample_rate, peak)
energy = strength * strength
is_beat = detect_beat(energy, state["energy_history"], frame_index,
state["last_beat_frame"], interval_ms, beat_ratio)
state["energy_history"].append(energy)
if len(state["energy_history"]) > 30:
state["energy_history"] = state["energy_history"][-30:]
if is_beat:
state["last_beat_frame"] = frame_index
state["beats"] += 1
state["rings"].append(spawn_beat_ring(tone))
if strength >= threshold and strength > 0.0:
born = spawn_particles(strength, tone, max_per_frame, is_beat)
state["particles"].extend(born)
state["spawned"] += len(born)
state["particles"] = advance_particles(state["particles"], life_frames, max_alive)
state["rings"] = advance_particles(state["rings"], ring_life_frames, 20)
offsets, sizes, colors = build_scatter_arrays(state["particles"], life_frames)
state["scatter"].set_offsets(offsets)
state["scatter"].set_sizes(sizes)
state["scatter"].set_edgecolors(colors)
r_offsets, r_sizes, r_colors = build_scatter_arrays(state["rings"], ring_life_frames)
state["ring_scatter"].set_offsets(r_offsets)
state["ring_scatter"].set_sizes(r_sizes)
state["ring_scatter"].set_edgecolors(r_colors)
return mAudio.is_playing()
def stop(state: dict, reason: str) -> None:
mAudio.stop()
info = mIO.realtime_plot(
setup, update,
start_func=start,
stop_func=stop,
title="음악 파티클 시각화 — 비트 링",
interval_ms=int(interval_ms),
duration_sec=None,
)
info["spawned"] = shared["spawned"]
info["beats"] = shared["beats"]
return info
def run_music_particles(df: pd.DataFrame) -> pd.DataFrame:
path = choose_audio_file()
if not path:
mIO.print("음악 파일을 선택하지 않아 종료합니다.")
return pd.DataFrame()
opts = ask_particle_options()
if not opts:
mIO.print("설정이 취소되어 종료합니다.")
return pd.DataFrame()
play_sec = int(opts["재생 길이(초)"])
info = visualize_particles(
path,
play_sec=play_sec,
life_ms=int(opts["원 수명(ms)"]),
max_per_frame=int(opts["프레임당 최대 원 수"]),
sensitivity=int(opts["감도(진폭 임계 %)"]),
beat_sensitivity=int(opts["비트 감도(%)"]),
interval_ms=int(opts["갱신 간격(ms)"]),
)
summary = pd.DataFrame([{
"파일": os.path.basename(path),
"재생요청(초)": play_sec,
"프레임수": info.get("frames", 0),
"생성된 원": info.get("spawned", 0),
"감지 비트": info.get("beats", 0),
"실행시간(초)": round(float(info.get("elapsed_seconds", 0.0)), 2),
"평균FPS": round(float(info.get("average_fps", 0.0)), 1),
"종료사유": info.get("stopped_by", ""),
"오류": info.get("error") or "",
}])
mIO.show_df(summary, title="파티클 시각화 실행 결과")
return summary
result = run_music_particles(df)