본문 바로가기

전자공학을 즐겁게/누구나 취미전자공학

버저(Buzzer)로 음악 연주하기

728x90
반응형

음악을 연주한다는 것은 어떤 높이의 소리를 어떤 길이로 내는 것을 연달아 이어 놓은 것입니다.

소리의 높이라는 것은 주파수를 달리함으로써 만들어 내게 됩니다. 소리라는 것은 공기의 떨림인데, 정해진 시간 동안 공기가 더 많이 떨리게 하면 높은 소리가 나는 것입니다. 버저에 다른 주파수의 신호를 인가하면 다른 소리가 나는 이유입니다. 

이런 서로 다른 높이의 소리를 얼마나 길게 내느냐에 따라 음악은 또 달라지게 됩니다. 

내친김에 버저로 간단하게 음악을 연주하게 만들어 봅시다. 예, 한 20년 전에는 버저로 벨소리를 만들었으니까요. 물론 버저의 특성이 모든 가청 주파수(audible frequency)에 일정한 것이 아니라서 깨끗하고 맑은 음악은 아니겠지만, 가락을 만들어 낼 수는 있을 것입니다.


소리의 높이와 계이름

우리가 흔히 음악에서 보는 음계의 음은 고유한 주파수를 가지는 소리입니다. 공학적인 접근으로 보면 우리가 흔히 사용하는 음계는 7음이 아니라 11음으로 이루어집니다.

도(C) - 도#(C#) - 레(D) - 레#(D#) - 미(E) - 파(F) - 파#(F#) - 솔(G) - 솔#(G#) - 라(A) - 라#(A#) - 시(B)

한 바퀴 돌아서 다시 도(C)가 나오면 1 옥타브(Octave)  차이가 난다라는 말을 흔히 들을 것입니다. 

결로부터 제시하자면 각 음은 다음 표와 같이 주파수를 갖습니다. 

계이름과 주파수의 관계

각각의 음은 앞음보다 $2^{\frac{1}{12}}$배 주파수가 높습니다. 

음악에서는 앞의 표에서 4 옥타브를 가온 음계라고 하는 모양인데, 전 잘 모르겠습니다. ^.^ 여기의 라(A)가 440㎐이니 이것을 기준으로 생각해 봅니다. 다음에 오는 라#(A#)의 주파수는 라(A)의 주파수보다 $2^{\frac{1}{12}}$배가 높습니다. 계산해 보세요. 

$$440Hz {\times} 2^{\frac{1}{12}} \approx 466.1638Hz$$

솔#(G#)은 라(A)의 주파수보다 $2^{\frac{1}{12}}$배가 낮습니다. 그래서,

$$440Hz {\times} 2^{-\frac{1}{12}} \approx 415.3047Hz$$

여기에서 인접음의 주파수보다 $2^{\frac{1}{12}}$배 높거나 낮은 것이 우리가 알고 있는 반음입니다. 

시(B)는 라#(A#)보다 주파수보다 $2^{\frac{1}{12}}$배가 높습니다. 그러면, 라(A)보다는 주파수가 $2^{\frac{2}{12}}$배가 높습니다. 수학에서 배운 $a^ba^c=a^{(b+c)}$에 의한 것입니다.

이렇게 가온 라(A)로부터 12단계를 올라가면 주파수가 $2^{\frac{12}{12}}=2$배가 높은 다음 옥타브의 라(A)에 도착합니다. 주파수가 2배가 되는 것을 1 옥타브(Octave)라고 하는 것이죠. 옥타브는 주파수가 2배 차이가 난다는 의미입니다. 물론 사전적으로는 8음 차이라는 것이지만, 공학적으로 보자면 2배의 주파수 차이를 의미합니다. 그래서 표를 잘 살펴보면 각 옥타브의 주파수는 인접한 옥타브의 주파수와 2배 차이가 나는 것을 알 수 있습니다. 나중에 혹시라도 전자공학이나 통신공학을 전공하는 사람이 있다면 주파수 응답을 다룰 때, 옥타브라는 말을 다시 듣게 될 것입니다. 참고로 주파수 10배의 차이를 디케이드(Decade)라고 하지요.

계속 기준을 라(A)로 잡고 이야기했는데, 그것이 편리한 것이 표를 보면 알겠지만, 딱 떨어지는 주파수예요. 시작이 편합니다.

이것으로부터 가온 음계 각 음의 주파수를 계산할 수가 있습니다. '도'에서부터 '시'까지 0부터 11까지 번호를 붙이고 이것을 계산에 사용한다면, '라'가 440Hz인 것으로부터

$$ \mathbf{Freq}_{\mathbf{middle}} = 440 {\times}2^{\frac{\mathbf{index}-9}{12}} $$

와 같이 주파수를 계산해 낼 수가 있지요. 

옥타브가 바뀌면 3 옥타브는 4옥타브의 절반, 2옥타브는 1/4, 5옥타브는 2배와 같이 주파수가 변합니다. 옥타브까지 계산에 넣으면,

$$ \mathbf{Freq}=2^{\mathbf{Octave-4}}{\times}440 {\times}2^{\frac{\mathbf{index}-9}{12}}$$

아저씨가 위의 표를 어디서 베껴 와서 그려 넣었다고 생각할지 몰라도 스프레드시트에 이 수식을 사용해서 계산된 결과입니다.

 

소리의 길이와 음표

템포(tempo)는 음악이 얼마나 빠른가 하는 것입니다.

1분 동안 4분 음표(Quater Note)가 몇 번 연주되는가와 같은 것입니다. 템포가 90이라고 했을 때, 다시 말해서 4분 음표가 1분 동안 90번 연주된다고 할 때, 그렇다면 4분 음표는 얼마나 길게 소리를 내어야 할까요?

$$60{\space\mathbf{sec}}{\times}{\frac{1}{90} \approx 0.666{\space\mathbf{sec}}}$$

4분 음표의 길이는 약 0.666초가 되겠네요. 온음표(Whole Note)는 이 길이의 4배이니 $4\times0.666{\mathbf{sec}}$가 되겠습니다.

그러면 일반식으로 온음표(Whole Note)의 길이 $T_{\mathbf{Whole}\space\mathbf{Note}}$는

$$T_{\mathbf{Whole}\space\mathbf{Note}}=60{\times}{\frac{1}{\mathrm{Tempo}}}{\times}4{\space\mathbf{sec}}$$

이에 따라서, 2분 음표의 길이는 $\frac{1}{2} \times T_{\mathbf{Whole}\space\mathbf{Note}}$, 4분 음표의 길이는 $\frac{1}{4} \times T_{\mathbf{Whole}\space\mathbf{Note}}$, 8분 음표의 길이는 $\frac{1}{8} \times T_{\mathbf{Whole}\space\mathbf{Note}}$, 16분 음표의 길이는 $\frac{1}{16} \times T_{\mathbf{Whole}\space\mathbf{Note}}$, $\dotsm$

이와 같이 될 수가 있겠지요.

템포에 따라 이 시간만큼 음을 유지하거나 쉼표의 경우에는 쉬면 됩니다.

 

왜 프로그램 안 만들고 계산만 이렇게 열심히 하느냐고요? 이것을 프로그램에 넣을 것이니까요. 

 

구상

아주 오래전에 8비트 개인용 컴퓨터가 있던 시절에 (이런 것으로 아저씨 연식 인증하면 안 되지만) 음악을 연주하던 명령이 있었습니다. 그때, 문자열을 명령어의 인자로 주어서 음악을 연주하도록 하였습니다. 그때 그 명령어가 생각이 나서 비슷하게 만들어 보기로 하였습니다. 

문자열의 각 요소는 여느 명령처럼 명령(command)과 인자(argument)로 되어 있습니다.

명령은 옥타브의 지정, 템포의 지정, 음높이, 그리고 쉼표로 생각했습니다.

 

O: 옥타브 지정. 인자는 1~8. 4는 가온 음계

T: 템포 지정. 인자는 1분 동안의 4분 음표 개수

C, C#, D, ..., A, A#, B: 도(C)부터 시(B)까지 음. 인자는 음표의 길이. 1은 온음표, 2는 2분 음표, 3은 4분 음표, 4는 8분 음표, ...

R: 쉼표. 인자는 쉼표의 길이. 1은 온쉼표, 2는 2분 쉼표, 3은 4분 쉼표, ...

 

이렇게 할 수 있을까 구상했습니다.

p = Buzzer(2) # Buzzer is connected to GPIO_2 (Pin 3). 
p.play('O4C3D3E3FGABO5C3R2')

 

구현

당연히 Buzzer라는 class를 생각했습니다. 

class Buzzer:

 

그리고, 단순히 문자열에 의해서 전달된 명령을 수행합니다. Buzzer._nextNote(notes)는 다음에 내어야 할 계이름과 길이, 남은 문자열을 리턴(return)하는 내부 메서드(method)입니다. 자세한 것은 전체 코드 참조하세요.

    def play(self, notes:str):
        '''
        The main method to play notes.
        '''

        playing_notes = notes
        while (len(playing_notes)!=0):
            # We get next note and param to play and keep the rest of notes.
            scale, note, playing_notes = self._nextNote(playing_notes)
            # print(scale, note, playing_notes)
            if scale == 'T':
                self.change_tempo(note)
                continue
            if scale == 'O':
                self.change_octave(note)
                continue

            self.play_tone(self.calculateFreq(scale), self.calculateDuration(note))

 

중요한 것은 이 글 첫머리에서 열심히 계산했던 것, 계이름에 따른 주파수의 계산, 템포에 따른 각 음표의 길이 계산이죠.

    def calculateFreq(self, scale)->float:
        '''
        Calculate and return the frequecy with a give scale.
        The argument, scale can be 
        'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'.
        '''
        if not ((scale in Buzzer.NOTES) or scale=='R'):
            raise ValueError
        
        if (scale=='R'):
            # If it is a Rest, return 0 of freq.
            freq = 0
        else:
            # The reference scale is 'A', whose frequency in O4 is 440 Hz.
            # Each scale has the frequency of 2**(1/12) times of that of previous one.
            # 440*(2**(octave-4))*2**((step-10)/12)
            freq = 440.0*(2**(self._octave-4))*2**(((Buzzer.NOTES.index(scale)+1)-10)/12)

        return freq

    def calculateDuration(self, note:int)->float:
        '''
        Calculate and return the duration of the tone.
        The argument, note has a type int and can be
        0: NOP, 1: Whole Note, 2: Half Note, 3: Quater Note,
        4: Eighth Note, 5: Sixteenth Note, 6: 32nd Notes, ...
        '''
        if type(note) !=  int:
            raise TypeError
        if note == 0: # This indicate no retention.
            return 0
        
        # The tempo here means how many quater notes are played in a minute.
        # So, a quatoer note is played for (1/tempo) and
        # a whole note is played for 4*(1/tempo).
        # A half note is played for a half of a whole note,
        # a quatoer note is played for a quater, and so on.
        duration = 4*(60/self._tempo)*(1/(2**(note-1)))

        return duration

 

주파수와 지속 시간만 안다면 이제 모두 끝난 것이죠. 버저에 해당 주파수의 구형파를 정해진 시간만큼 출력하면 끝입니다. GPIO의 PWM 설정을 이용하여 피에조 버저를 구동하는 것은 피에조 버저 구동에 관한 글에서 다루었었죠.

코드를 모두 여기에 모두 붙여 놓을 수 없으니, 다음 GitHub repository를 참조하세요.

https://github.com/sangyoungn/play_with_RPi/tree/main/buzzer_play

 

GitHub - sangyoungn/play_with_RPi: Example Codes for Raspberry Pi

Example Codes for Raspberry Pi. Contribute to sangyoungn/play_with_RPi development by creating an account on GitHub.

github.com

이 repository의 buzzer.py 보면 됩니다. 

 

하드웨어의 구성은 피에조 버저 구동에 관한 글에 있는 어떤 것이라도 상관없습니다. GPIO 하나로 피에조 버저에 신호를 인가하기만 하면 됩니다.

 

데모 코드에 제가 아는 노래 악보를 보고 붙여 놓았습니다.

if __name__ == '__main__':
    p = Buzzer(2) # Buzzer is connected to GPIO_2 (Pin 3). 

    p.play('O4C3D3E3FGABO5C3R2')

    p.play('O4T90A4A5G5F4G4A4R0A4R0A3')
    p.play('G4R0G4R0G3A4R0A4R0A3')
    p.play('A4A5G5F4G4A4R0A4R0A3')
    p.play('G4R0G4A4A5G5F2')
    p.play('R3')

    p.play('O4T140G3R0G3E4F4G3A3R0A3G2G3O5C3E3D4C4D2D3R3')
    p.play('E3R0E3D3R0D3C3D4C4O4A3A3G3R0G3R0G3E4D4C2C3R3')
    p.play('D3R0D3E3C3D3R0D3E3G3A3O5C3E3D4C4D2D3R3')
    p.play('E3R0E3D3R0D3C3D4C4O4A3R0A3G3R0G3R0G3E4D4C2C3R3')

무슨 노래인지 맞춰 보세요.

728x90
반응형