글자로 음악 만들기
설명
입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요
필요 데이터
문장 하나 (실행할 때 입력) · 음계·빠르기 선택
출력
WAV 음악 파일 + 음표 표 + 실시간 파형
필요 패키지
- numpy
- matplotlib
- miniaudio
태그
creative, realtime
작성자
MDS 메이커 | MDS 1.0
입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요
Turns your text into a melody, then plays it with a live waveform
🔍 클릭하면 원본 크기로 보기README
입력한 문장을 음계로 바꿔 멜로디를 만들고, 재생하면서 파형을 실시간으로 보여 줘요
문장 하나 (실행할 때 입력) · 음계·빠르기 선택
WAV 음악 파일 + 음표 표 + 실시간 파형
creative, realtime
MDS 메이커 | MDS 1.0
# 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)