파이썬으로 작곡가 되어보기 2탄

by 박정수

오늘도 파이썬과 동행하는 작곡가가 되어보기 시리즈입니다. 오늘은 단순한 음계에서 벗어나 초등시절 명작인 학교종을 연주(?) 해 볼게요.



그리고 기타 소리도 만들어 볼게요.


그리고 코드는 다음 주에 소개하겠지만 자꾸 소리가 Upgrade 되고 있어서 진짜로 내가 작곡가가 될 수도 있다는 희망을 다음 주까지 간직하기를 바라요

OIG4.RZOLsNQKU.jpg


우주인이 연주하는 것 같죠?


그리고 다음은 앞 두 편의 코드인데, 외우려고도, 배우려고도 하지 말고, 골동품 보관하듯 잘 보관만 하세요. 아직까지는.... 여러분들이 작곡가가 되려 한다면 이 정도야 뭐 하루면....


# -*- coding: utf-8 -*-

"""

초보 작곡가를 위한 파이썬 코딩 2탄

- 피아노 톤으로 '학교종이 땡땡땡' 연주 WAV 생성

- 기타 톤(Karplus-Strong)으로 간단 작곡 WAV 생성

필요 패키지: numpy (없으면 pip install numpy)

"""

import wave, struct, random, math

import numpy as np


SR = 44100 # 샘플레이트

A4 = 440.0 # 표준 튜닝 (평균율)

EPS = 1e-9


# ---------------------------

# 1) 음고(주파수) 계산

# ---------------------------

NOTE_INDEX = {'도':0,'도#':1,'레':2,'레#':3,'미':4,'파':5,'파#':6,'솔':7,'솔#':8,'라':9,'라#':10,'시':11}

NAME_TO_EN = {'도':'C','레':'D','미':'E','파':'F','솔':'G','라':'A','시':'B'}


def freq_equal_temper(note_kr: str, octave: int, a4=440.0):

"""평균율: MIDI 번호로 변환 후 주파수 계산"""

# C4(도4)=261.63 기준: MIDI 60

semitone = NOTE_INDEX[note_kr]

# octave 기준 C계 산정

midi = 12*(octave+1) + semitone # MIDI 번호(참고: C4=60)

return a4 * (2.0 ** ((midi - 69)/12.0))


# 피타고라스(다장조 기준) 비율

PYTH_RATIO = {

'도': 1.0,

'레': 9/8,

'미': 81/64,

'파': 4/3,

'솔': 3/2,

'라': 27/16,

'시': 243/128

}


def freq_pythagorean(note_kr: str, octave: int, c4=261.63):

"""피타고라스 조율(다장조 기준) 단순 비율 사용"""

# 기준: C4(도4)=261.63

base = c4 * (2 ** (octave - 4)) # 옥타브 이동

ratio = PYTH_RATIO[note_kr]

return base * ratio


def get_freq(note_kr: str, octave: int, temperament='equal'):

return freq_equal_temper(note_kr, octave) if temperament=='equal' else freq_pythagorean(note_kr, octave)


# ---------------------------

# 2) ADSR & 합성기

# ---------------------------

def adsr_envelope(n_samples, attack=0.01, decay=0.12, sustain=0.6, release=0.12):

"""간단 ADSR(비율 기반)"""

if n_samples <= 0: return np.zeros(0)

a = int(n_samples*attack)

d = int(n_samples*decay)

r = int(n_samples*release)

s = max(0, n_samples - a - d - r)


env = np.zeros(n_samples)

# Attack: 0->1 선형

if a>0: env[:a] = np.linspace(0.0, 1.0, a, endpoint=False)

# Decay: 1->sustain

if d>0: env[a:a+d] = np.linspace(1.0, sustain, d, endpoint=False)

# Sustain

if s>0: env[a+d:a+d+s] = sustain

# Release: sustain->0

if r>0: env[a+d+s:] = np.linspace(sustain, 0.0, r, endpoint=True)

return env


def synth_piano(freq, duration_sec):

"""피아노 비슷한 톤: 배음 + 지수감쇠 + ADSR"""

n = max(1, int(SR*duration_sec))

t = np.arange(n)/SR

# 배음(하모닉) 간단 합성

y = (1.0*np.sin(2*math.pi*freq*t) +

0.6*np.sin(2*math.pi*2*freq*t) +

0.3*np.sin(2*math.pi*3*freq*t) +

0.2*np.sin(2*math.pi*4*freq*t))

# 지수형 감쇠 + ADSR

y *= np.exp(-3.0*t)

y *= adsr_envelope(n, attack=0.02, decay=0.15, sustain=0.5, release=0.15)

return y


def karplus_strong(freq, duration_sec, decay=0.996):

"""Karplus-Strong: 기타(현) 톤 근사"""

n_total = max(1, int(SR*duration_sec))

N = max(2, int(SR/max(freq, EPS))) # delay line 길이

# 초기 잡음 버퍼

buf = np.array([random.uniform(-1,1) for _ in range(N)], dtype=np.float32)

y = np.zeros(n_total, dtype=np.float32)

idx = 0

for i in range(n_total):

y[i] = buf[idx]

# 평균 후 감쇠

nxt = decay * 0.5 * (buf[idx] + buf[(idx+1) % N])

buf[idx] = nxt

idx = (idx + 1) % N

# 간단한 ADSR로 어택 강조

y = y.astype(np.float64)

y *= adsr_envelope(n_total, attack=0.005, decay=0.2, sustain=0.7, release=0.25)

return y


# ---------------------------

# 3) 렌더링 & 파일 저장

# ---------------------------

def write_wav(path, data):

"""[-1,1] float -> int16 WAV 저장"""

if data.size == 0: return

# 노멀라이즈(클리핑 방지)

peak = np.max(np.abs(data)) + EPS

data16 = np.int16((data/peak) * 32767)

with wave.open(path, 'wb') as wf:

wf.setnchannels(1)

wf.setsampwidth(2) # int16

wf.setframerate(SR)

wf.writeframes(data16.tobytes())


def beat_to_seconds(beats, bpm):

return (60.0/bpm)*beats


# ---------------------------

# 4) '학교종이 땡땡땡' 피아노 렌더

# ---------------------------

def render_school_bell_piano(bpm=120, temperament='equal'):

# 계이름(다장조, 옥타브는 노래 느낌상 4옥타브 중심)

melody = [

('솔',1),('솔',1),('라',1),('라',1),('솔',1),('솔',1),('미',2),

('솔',1),('솔',1),('미',1),('미',1),('레',2),

('솔',1),('솔',1),('라',1),('라',1),('솔',1),('솔',1),('미',2),

('솔',1),('미',1),('레',1),('미',1),('도',2)

]

track = np.zeros(0, dtype=np.float64)

for note_kr, beats in melody:

dur = beat_to_seconds(beats, bpm)

f = get_freq(note_kr, octave=4, temperament=temperament)

y = synth_piano(f, dur)

track = np.concatenate([track, y])

return track


# ---------------------------

# 5) 기타 톤으로 간단 작곡(스트럼 + 짧은 멜로디)

# ---------------------------

CHORD_NOTES = {

# 간단한 코드 보이싱(기타 느낌 범위)

'C': [('도',3), ('미',3), ('솔',3), ('도',4), ('미',4)],

'F': [('파',3), ('라',3), ('도',4), ('파',4)],

'G': [('솔',3), ('시',3), ('레',4), ('솔',4)]

}


def strum_chord(chord_name, duration_beats, bpm=100, temperament='equal', direction='down', gap_ms=18):

"""코드 스트럼: 각 음을 ms 간격으로 차례로 발음"""

notes = CHORD_NOTES[chord_name]

if direction=='up':

notes = list(reversed(notes))

base_dur = beat_to_seconds(duration_beats, bpm)

total = np.zeros(int(SR*base_dur)+SR//10, dtype=np.float64) # 여유 tail

for i, (note_kr, octv) in enumerate(notes):

f = get_freq(note_kr, octave=octv, temperament=temperament)

start = int((i * gap_ms/1000.0) * SR)

y = karplus_strong(f, base_dur*0.95) # tail 약간 짧게

end = start + y.size

if end > total.size:

# 크기 확장

pad = np.zeros(end - total.size, dtype=np.float64)

total = np.concatenate([total, pad])

total[start:end] += y

return total


def render_guitar_composition(bpm=100, temperament='equal'):

"""C–F–C–G–C 진행 + 짧은 멜로디(도-미-솔-라-솔)"""

prog = [('C',4), ('F',4), ('C',4), ('G',4), ('C',4)]

track = np.zeros(0, dtype=np.float64)

for ch, beats in prog:

part = strum_chord(ch, beats, bpm=bpm, temperament=temperament, direction='down', gap_ms=18)

track = np.concatenate([track, part])


# 간단한 톱라인 멜로디(펜타토닉 느낌) 오버레이

topline = [('도',4,1), ('미',4,1), ('솔',4,1), ('라',4,1), ('솔',4,2)]

mel = np.zeros(0, dtype=np.float64)

for note_kr, octv, beats in topline:

dur = beat_to_seconds(beats, bpm)

f = get_freq(note_kr, octave=octv, temperament=temperament)

y = karplus_strong(f, dur)

mel = np.concatenate([mel, y])

# 멜로디 길이를 코드 트랙에 맞추어 앞쪽에 얹기

mix = track.copy()

L = min(mix.size, mel.size)

mix[:L] += 0.9*mel[:L] # 멜로디 살짝 크게

return mix


# ---------------------------

# 6) 메인

# ---------------------------

if __name__ == "__main__":

# (A) 피아노로 '학교종이 땡땡땡'

piano_track = render_school_bell_piano(bpm=120, temperament='equal') # 'pythagorean'으로 바꿔도 OK

write_wav("school_bell_piano.wav", piano_track)

print("생성: school_bell_piano.wav")


# (B) 기타 톤으로 간단 작곡

guitar_track = render_guitar_composition(bpm=100, temperament='equal')

write_wav("guitar_intro_composition.wav", guitar_track)

print("생성: guitar_intro_composition.wav")

이전 18화피타고라스가 만든 음계로 도레미 파솔라시도 만들기