← .mpy 공유

음악 파티클 비주얼라이저

음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화

English

Real-time particle visualizer that pulses rings on every beat

음악 파티클 비주얼라이저 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

음악 파티클 비주얼라이저

설명

음악을 틀면 비트에 맞춰 원과 링이 퍼져 나가는 실시간 시각화

필요 데이터

음악 파일 1개 (WAV/MP3/FLAC/OGG)

출력

실시간 파티클 애니메이션 + 비트 감지 요약 표

필요 패키지

  • numpy
  • matplotlib
  • miniaudio

태그

creative, visualization, realtime

작성자

MDS 메이커 | MDS 1.0

Source · 276줄 (실행 전에 꼭 확인하세요 — 펼치기)
# 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)