← .mpy 공유

글자로 음악 만들기

입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요

English

Turns your text into a melody, then plays it with a live waveform

글자로 음악 만들기 실행 결과 미리보기🔍 클릭하면 원본 크기로 보기

README

글자로 음악 만들기

설명

입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요

필요 데이터

문장 하나 (실행할 때 입력) · 음계·빠르기 선택

출력

WAV 음악 파일 + 음표 표 + 실시간 파형

필요 패키지

  • numpy
  • matplotlib
  • miniaudio

태그

creative, realtime

작성자

MDS 메이커 | MDS 1.0

Source · 237줄 (실행 전에 꼭 확인하세요 — 펼치기)
# MDS_TITLE: 글자로 음악 만들기
# MDS_REQUIRES: numpy, matplotlib, miniaudio
# MDS_DESC: 입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요
# MDS_DESC[en]: Turns your text into a melody, then plays it with a live waveform
# MDS_TAGS: creative, realtime
# MDS_INPUT: 문장 하나 (실행할 때 입력) · 음계·빠르기 선택
# MDS_OUTPUT: WAV 음악 파일 + 음표 표 + 실시간 파형
# MDS_AUTHOR: MDS 메이커
# MDS_VERSION: 1.0
# MDS_DATE: 2026-09-15
#
import os
import wave
from FileDialogManager import MY_DATA_PATH

SCALES = {
    "펜타토닉(무난)": [0, 2, 4, 7, 9],
    "장음계(밝음)": [0, 2, 4, 5, 7, 9, 11],
    "단음계(어두움)": [0, 2, 3, 5, 7, 8, 10],
}
REST_CHARS = " \t\n.,!?;:·…-~'\"()[]{}"
MAX_CHARS = 200
SAMPLE_RATE = 44100


def ask_song_options() -> dict:
    values = mIO.form([
        {"type": "text",  "label": "문자열",      "default": "안녕하세요 오늘도 좋은 하루"},
        {"type": "radio", "label": "음계",        "options": list(SCALES.keys()), "default": "펜타토닉(무난)"},
        {"type": "spin",  "label": "템포(%)",     "min": 10, "max": 200, "default": 100, "step": 10},
        {"type": "spin",  "label": "옥타브 이동",  "min": -2, "max": 2,  "default": 0,   "step": 1},
        {"type": "check", "label": "재생하며 파형 보기", "default": True},
        {"type": "text",  "label": "저장 파일명",  "default": "my_song"},
    ], title="문자열 작곡 설정 (템포 10% = 10배 느리게)")
    return values or {}


def text_to_notes(text: str, scale_name: str, tempo_pct: int, octave_shift: int) -> list[dict]:
    # 글자 코드값을 음계 칸수로 변환 — 반음 없는 음계만 쓰므로 어떤 글자를 넣어도 불협화음이 없다
    # 템포는 100%가 기준, 10%면 10배 느리게(한 음이 2~4.5초)
    steps = SCALES[scale_name]
    n_steps = len(steps)
    span = n_steps * 2
    base_freq = 261.63 * (2.0 ** octave_shift)
    tempo = 100.0 / max(10, tempo_pct)
    lengths = [0.20, 0.30, 0.45]

    notes = []
    for i, ch in enumerate(text[:MAX_CHARS]):
        if ch in REST_CHARS:
            notes.append({"freq": 0.0, "dur": 0.25 * tempo, "amp": 0.0, "char": ch})
            continue
        code = ord(ch)
        idx = code % span
        semitone = steps[idx % n_steps] + 12 * (idx // n_steps)
        notes.append({
            "freq": base_freq * (2.0 ** (semitone / 12.0)),
            "dur": lengths[code % 3] * tempo,
            "amp": 0.85 if i % 4 == 0 else 0.60,   # 4글자마다 강세로 박자감
            "char": ch,
        })
    return notes


def estimate_duration(notes: list[dict]) -> float:
    return float(sum(note["dur"] for note in notes))


def render_note(freq: float, dur: float, amp: float) -> np.ndarray:
    n = max(1, int(dur * SAMPLE_RATE))
    if freq <= 0.0 or amp <= 0.0:
        return np.zeros(n, dtype="float32")

    t = np.arange(n, dtype="float32") / SAMPLE_RATE
    tone = (np.sin(2 * np.pi * freq * t)
            + 0.30 * np.sin(4 * np.pi * freq * t)
            + 0.15 * np.sin(6 * np.pi * freq * t)).astype("float32")

    env = np.ones(n, dtype="float32")
    attack = min(n // 4, int(0.010 * SAMPLE_RATE))
    release = min(n - attack, int(0.060 * SAMPLE_RATE))
    if attack > 0:
        env[:attack] = np.linspace(0.0, 1.0, attack, dtype="float32")
    if release > 0:
        env[n - release:] = np.linspace(1.0, 0.0, release, dtype="float32")

    return (tone * env * (amp / 1.45)).astype("float32")


def synthesize(notes: list[dict]) -> np.ndarray:
    # 조각을 리스트에 모았다가 루프 밖에서 한 번만 합친다 (7-11절)
    parts = []
    for note in notes:
        parts.append(render_note(note["freq"], note["dur"], note["amp"]))
    if not parts:
        return np.zeros(1, dtype="float32")
    signal = np.concatenate(parts)
    del parts
    peak = float(np.max(np.abs(signal)))
    if peak > 0:
        signal = (signal / peak * 0.9).astype("float32")
    return signal


def save_wav(signal: np.ndarray, filename: str) -> str:
    name = filename.strip() or "my_song"
    if not name.lower().endswith(".wav"):
        name = name + ".wav"
    path = os.path.join(MY_DATA_PATH, name)
    pcm = (np.clip(signal, -1.0, 1.0) * 32767.0).astype("int16")
    with wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(SAMPLE_RATE)
        wf.writeframes(pcm.tobytes())
    del pcm
    return path


def build_note_table(notes: list[dict]) -> pd.DataFrame:
    rows, cursor = [], 0.0
    for note in notes:
        rows.append({
            "start_sec": round(cursor, 3),
            "char": note["char"] if note["freq"] > 0 else "(쉼)",
            "freq_hz": round(note["freq"], 2),
            "dur_sec": round(note["dur"], 3),
            "amp": round(note["amp"], 2),
        })
        cursor += note["dur"]
    return pd.DataFrame(rows)


def play_with_waveform(wav_path: str, signal: np.ndarray) -> dict:
    audio = mAudio.load(wav_path)
    total_sec = signal.size / SAMPLE_RATE
    step = max(1, signal.size // 5000)
    view_y = np.ascontiguousarray(signal[::step])
    view_x = (np.arange(view_y.size, dtype="float32") * step / SAMPLE_RATE)

    def setup(fig, ax) -> dict:
        plt.rcParams['font.family'] = 'Malgun Gothic'
        plt.rcParams['axes.unicode_minus'] = False
        ax.plot(view_x, view_y, linewidth=0.7, color="#4C72B0")
        ax.set_xlim(0.0, max(0.1, total_sec))
        ax.set_ylim(-1.05, 1.05)
        ax.set_xlabel("시간(초)")
        ax.set_ylabel("진폭")
        ax.grid(True, alpha=0.3)
        cursor = ax.axvline(0.0, color="#C44E52", linewidth=1.4)
        return {"cursor": cursor}

    def start(state: dict) -> None:
        mAudio.play(audio)

    def update(elapsed_sec: float, frame_index: int, state: dict, ax) -> bool:
        pos = float(mAudio.get_position())
        state["cursor"].set_xdata([pos, pos])
        ax.set_title(f"재생 중 {pos:5.1f}s / {total_sec:.1f}s", fontsize=11, fontweight="semibold")
        return mAudio.is_playing()

    def stop(state: dict, reason: str) -> None:
        mAudio.stop()   # 여러 경로에서 반복 호출돼도 안전(멱등) — 5-4절

    return mIO.realtime_plot(setup, update, start_func=start, stop_func=stop,
                             title="문자열 작곡 — 재생", interval_ms=50, duration_sec=None)


def plot_static_waveform(signal: np.ndarray) -> None:
    plt.rcParams['font.family'] = 'Malgun Gothic'
    plt.rcParams['axes.unicode_minus'] = False
    step = max(1, signal.size // 5000)
    y = signal[::step]
    x = np.arange(y.size, dtype="float32") * step / SAMPLE_RATE
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.plot(x, y, linewidth=0.7, 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_text_to_music(df: pd.DataFrame) -> str:
    opts = ask_song_options()
    if not opts:
        mIO.print("설정이 취소되어 종료합니다.")
        return "취소됨"

    text = str(opts["문자열"]).strip()
    if not text:
        mIO.print("문자열이 비어 있습니다.")
        return "입력 없음"
    if len(text) > MAX_CHARS:
        mIO.notify(f"앞 {MAX_CHARS}글자까지만 사용합니다")

    tempo_pct = int(opts["템포(%)"])
    notes = text_to_notes(text, str(opts["음계"]), tempo_pct, int(opts["옥타브 이동"]))
    est_sec = estimate_duration(notes)
    if est_sec > 180 and not mIO.confirm(
            f"템포 {tempo_pct}%로는 약 {est_sec / 60:.1f}분짜리 곡이 됩니다. 계속할까요?"):
        mIO.print("사용자가 취소했습니다.")
        return "취소됨"

    signal = synthesize(notes)
    saved = save_wav(signal, str(opts["저장 파일명"]))
    note_table = build_note_table(notes)

    n_notes = int((note_table["freq_hz"] > 0).sum())
    n_rests = len(note_table) - n_notes
    total_sec = signal.size / SAMPLE_RATE
    size_mb = os.path.getsize(saved) / (1024 ** 2)

    play_info = {}
    if bool(opts["재생하며 파형 보기"]):
        play_info = play_with_waveform(saved, signal)
    else:
        plot_static_waveform(signal)
    del signal

    mIO.show_df(note_table, title=f"글자 → 음 변환표 ({len(note_table)}개)")
    del note_table

    mIO.print(
        f"작곡 완료: 음 {n_notes}개, 쉼표 {n_rests}개, 길이 {total_sec:.1f}초\n"
        f"음계 {opts['음계']}, 템포 {tempo_pct}%, 옥타브 {int(opts['옥타브 이동']):+d}\n\n"
        f"WAV 저장 완료 ({size_mb:,.2f}MB)\n{saved}\n"
        f"폴더: {os.path.dirname(saved)}\n\n"
        + (f"재생 종료: {play_info.get('stopped_by', '')}"
           + (f" / 오류: {play_info['error']}" if play_info.get("error") else "")
           if play_info else "같은 문자열을 다시 넣으면 같은 멜로디가 나옵니다.")
    )
    return f"{n_notes}음 / {total_sec:.1f}초 — {os.path.basename(saved)}"


result = run_text_to_music(df)