← .mpy 공유
사용자 공유학교·과학시각화

음악 파일 파형 뽑아내기

WAV·MP3 음악에서 원하는 구간의 파형을 표로 뽑아 CSV로 저장하고 그래프로 확인해요

English

Extracts a section of an audio file as a waveform table (CSV) with a preview plot

음악 파일 파형 뽑아내기 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

음악 파일 파형 뽑아내기

설명

WAV·MP3 음악에서 원하는 구간의 파형을 표로 뽑아 CSV로 저장하고 그래프로 확인해요

필요 데이터

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

출력

파형 CSV + 미리보기 그래프

필요 패키지

  • pandas
  • matplotlib
  • miniaudio

태그

science, visualization

작성자

MDS 메이커 | MDS 1.0

Source · 186줄 (실행 전에 꼭 확인하세요 — 펼치기)
# MDS_TITLE: 음악 파일 파형 뽑아내기
# MDS_REQUIRES: pandas, matplotlib, miniaudio
# MDS_DESC: WAV·MP3 음악에서 원하는 구간의 파형을 표로 뽑아 CSV로 저장하고 그래프로 확인해요
# MDS_DESC[en]: Extracts a section of an audio file as a waveform table (CSV) with a preview plot
# MDS_TAGS: science, visualization
# MDS_INPUT: 음악 파일 1개 (WAV/MP3/FLAC/OGG)
# MDS_OUTPUT: 파형 CSV + 미리보기 그래프
# MDS_AUTHOR: MDS 메이커
# MDS_VERSION: 1.0
# MDS_DATE: 2026-09-15
#
import os
from FileDialogManager import MY_DATA_PATH


def choose_audio_file() -> str:
    path = mIO.open_file_single(
        dialog_title="음악 파일 선택",
        file_filter="오디오 파일 (*.mp3 *.wav *.flac *.ogg)",
        initial_dir=None,
    )
    return path or ""


def ask_wave_options(default_name: str) -> dict:
    values = mIO.form([
        {"type": "spin",  "label": "시작 시각(초)",  "min": 0, "max": 600, "default": 0,  "step": 1},
        {"type": "spin",  "label": "추출 길이(초)",  "min": 1, "max": 60,  "default": 10, "step": 1},
        {"type": "spin",  "label": "샘플 간격(1=원본)", "min": 1, "max": 100, "default": 1, "step": 1},
        {"type": "radio", "label": "채널 처리",
         "options": ["왼쪽 채널만", "좌우 평균(모노)", "좌·우 모두"],
         "default": "왼쪽 채널만"},
        {"type": "text",  "label": "저장 파일명",    "default": default_name},
    ], title="원본 파형 추출 설정")
    return values or {}


def extract_channels(samples: object, nch: int, start_frame: int, end_frame: int,
                     step: int, mode: str) -> dict:
    # 블록 단위로 끊어 읽어 원본 샘플값을 그대로 모은다 — 요약하지 않는다.
    # 무거운 수치 루프이므로 진행바를 쓰지 않는다(7-12절).
    block_frames = 200000
    left_parts, right_parts = [], []
    need_right = (mode != "왼쪽 채널만") and nch > 1

    cursor = start_frame
    n_blocks = max(1, (end_frame - start_frame + block_frames - 1) // block_frames)
    for _ in range(n_blocks):
        if cursor >= end_frame:
            break
        stop = min(cursor + block_frames, end_frame)
        raw = np.asarray(samples[cursor * nch: stop * nch], dtype="float32")
        if raw.size == 0:
            break
        left_parts.append(np.ascontiguousarray(raw[0::nch][::step]) if nch > 1
                          else np.ascontiguousarray(raw[::step]))
        if need_right:
            right_parts.append(np.ascontiguousarray(raw[1::nch][::step]))
        del raw
        cursor = stop

    out = {"left": np.concatenate(left_parts) if left_parts else np.empty(0, dtype="float32")}
    if need_right and right_parts:
        out["right"] = np.concatenate(right_parts)
    del left_parts, right_parts
    return out


def build_wave_table(channels: dict, sample_rate: int, start_frame: int,
                     step: int, mode: str) -> pd.DataFrame:
    left = channels["left"]
    right = channels.get("right")
    n = left.size
    times = ((np.arange(n, dtype="float64") * step + start_frame) / sample_rate).astype("float32")

    if mode == "좌우 평균(모노)" and right is not None:
        m = min(n, right.size)
        return pd.DataFrame({
            "time_sec": times[:m],
            "amplitude": ((left[:m] + right[:m]) * 0.5).astype("float32"),
        })
    if mode == "좌·우 모두" and right is not None:
        m = min(n, right.size)
        return pd.DataFrame({
            "time_sec": times[:m],
            "amp_left": left[:m],
            "amp_right": right[:m],
        })
    return pd.DataFrame({"time_sec": times, "amplitude": left})


def save_wave_csv(out: pd.DataFrame, filename: str) -> str:
    name = filename.strip() or "audio_wave"
    if not name.lower().endswith(".csv"):
        name = name + ".csv"
    path = os.path.join(MY_DATA_PATH, name)
    out.to_csv(path, index=False, encoding="utf-8-sig")
    return path


def plot_wave_preview(out: pd.DataFrame, value_col: str) -> None:
    plt.rcParams['font.family'] = 'Malgun Gothic'
    plt.rcParams['axes.unicode_minus'] = False
    n = len(out)
    step = max(1, n // 5000)   # 그리기용으로만 간격을 벌린다(6절)
    view = out.iloc[::step]
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.plot(view["time_sec"], view[value_col], linewidth=0.6, color="#4C72B0")
    ax.set_title("추출 파형 미리보기", fontsize=12, fontweight="semibold")
    ax.set_xlabel("시간(초)"); ax.set_ylabel("진폭")
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
    plt.close(fig)


def run_extract_waveform(df: pd.DataFrame) -> str:
    path = choose_audio_file()
    if not path:
        mIO.print("음악 파일을 선택하지 않아 종료합니다.")
        return "취소됨"

    default_name = os.path.splitext(os.path.basename(path))[0] + "_wave"
    opts = ask_wave_options(default_name)
    if not opts:
        mIO.print("설정이 취소되어 종료합니다.")
        return "취소됨"

    start_sec = int(opts["시작 시각(초)"])
    length_sec = int(opts["추출 길이(초)"])
    step = max(1, int(opts["샘플 간격(1=원본)"]))
    mode = str(opts["채널 처리"])

    est_rows = int(44100 * length_sec / step)
    if est_rows > 600000 and not mIO.confirm(
            f"예상 {est_rows:,}행 — CSV가 커지고 시간이 걸립니다. 계속할까요?"):
        mIO.print("사용자가 취소했습니다.")
        return "취소됨"

    mIO.print(
        f"원본 파형 추출을 시작합니다 (약 {est_rows:,}행 예상).\n"
        "진행바 없이 처리되므로 완료 안내가 뜰 때까지 기다려 주세요."
    )

    audio = mAudio.load(path, duration_sec=float(start_sec + length_sec))
    sample_rate = int(audio.sample_rate)
    nch = int(audio.nchannels)
    total_frames = len(audio.samples) // nch

    start_frame = min(int(start_sec * sample_rate), total_frames)
    end_frame = min(start_frame + int(length_sec * sample_rate), total_frames)
    if end_frame <= start_frame:
        mIO.print("지정한 구간에 데이터가 없습니다. 시작 시각을 줄여 다시 시도하세요.")
        return "구간 없음"

    channels = extract_channels(audio.samples, nch, start_frame, end_frame, step, mode)
    out = build_wave_table(channels, sample_rate, start_frame, step, mode)
    del channels
    if out.empty:
        mIO.print("추출된 샘플이 없습니다.")
        return "추출 결과 없음"

    saved = save_wave_csv(out, str(opts["저장 파일명"]))
    n_rows, n_cols = len(out), len(out.columns)
    value_col = "amp_left" if "amp_left" in out.columns else "amplitude"
    peak = float(np.max(np.abs(out[value_col].to_numpy())))
    eff_rate = sample_rate / step
    size_mb = os.path.getsize(saved) / (1024 ** 2)

    plot_wave_preview(out, value_col)
    mIO.show_df(out, title=f"원본 파형 ({n_rows:,}행 × {n_cols}열)")
    del out

    mIO.print(
        f"파형 추출 완료: {n_rows:,}행 / {n_cols}열\n"
        f"구간 {start_sec}~{start_sec + length_sec}초, 실효 샘플레이트 {eff_rate:,.0f}Hz\n"
        f"원본 {sample_rate:,}Hz / {nch}채널, 최대 진폭 {peak:,.1f}\n"
        f"채널 처리: {mode}, 샘플 간격 {step}\n\n"
        f"CSV 저장 완료 ({size_mb:,.2f}MB)\n{saved}\n"
        f"폴더: {os.path.dirname(saved)}\n\n"
        "각 행이 실제 샘플 하나입니다 — 구간 요약이 아니라 파형 원본값입니다."
    )
    return f"{n_rows:,}행 저장 완료 — {os.path.basename(saved)}"


result = run_extract_waveform(df)