DAC 없이 Pi Pico에서 오디오 파일 재생하기
Raspberry Pico가 어느새 임베디드 개발에서 제가 가장 선호하는 칩이 되었습니다. 잘 만들어진 데다 내구성도 뛰어나고, 영리함과 열정이 느껴지는 기능들로 가득합니다(GPIO를 구동하는 스테이트 머신은 정말 킬러 기능입니다!). 가장 큰 약점이었던 연결성 부족은 이제 W 모델로 해결되었습니다. 데이터시트도 훌륭해서 칩의 모든 측면을 빠짐없이 문서화하고 있습니다. 게다가 MicroPython 지원도 탄탄한데(저는 요즘 MicroPython을 많이 사용하고 있습니다), C SDK 환경도 그럭저럭 쓸 만합니다. 다만 요즘 유행처럼 쓸데없는 복잡성으로 가득하다는 점만 빼면 말이죠. Makefile을 생성하기 위해 cmake 빌드 시스템을 쓰고, 이것저것(사용 라이브러리, 디버그 출력 등)을 정의하는 파일들을 만들어야 하는 식인데, 작은 장치를 위한 작은 프로그램을 컴파일하겠다는 목표에 비하면 완전히 과도합니다. 아니, 그보다 더합니다. W 모델과 비 W 모델 차이를 제외하면 기능이 고정된 FIXED 하드웨어용 프로그램을 만들기 위해 이런 복잡성을 감수해야 하니까요. 오늘날 소프트웨어가 얼마나 엉망인지에 대한 푸념은 이쯤 하겠습니다만, 그래도 기억해 둘 필요는 있습니다.
이런 MCU로 하고 싶은 멋진 일 중 하나는 소리를 만들어 내는 것입니다. 가장 간단한 방법은 칩에 내장된 PWM 기능을 이용하는 것입니다. GPIO를 원하는 주파수로 0과 1 사이를 단순히 오가도록 설정할 수 있습니다. 예를 들면 다음과 같습니다:
from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(400)
pwm.duty_u16(1000)Pico의 GND와 1번 핀에 피에조 부저를 연결했다고 가정하면, 400hz 주파수의 구형파 소리를 듣게 될 것입니다. 그런데 구형파만큼 듣기 거북한 소리도 드뭅니다. 좀 더 나은 방법을 찾아보겠습니다. 여기서는 사인파 생성 같은 중간 단계는 모두 건너뛰고 바로 wav 파일 재생으로 넘어가겠습니다. 그 방법을 알게 되면 사인파나 노이즈, 이런 파형들의 엔벨로프 등 원하는 다른 파형도 쉽게 만들 수 있습니다.
이제 이런 의문이 드실 겁니다. Pico는 핀을 하이(high)나 로우(low)로만 전환할 수 있는데, 어떻게 wav 파일을 재생할 만큼 복잡한 파형을 만들 수 있을까? 제대로 된 비구형파는 여러 단계의 레벨로 이루어져 있으니 DAC가 필요하지 않을까 하고 말입니다! 다행히 DAC 없이도, Pico의 핀 하나만으로 이 모든 것을 할 수 있습니다.
복잡한 소리 생성의 원리
여기서 배경지식을 너무 깊게 다루고 싶지는 않습니다. 다만 알아두어야 할 것은, 단순히 출력의 최소 레벨과 최대 레벨 사이를 오가는 단순한 구형파가 아니라면, 다음과 같은 중간 단계가 필요하다는 점입니다:
S0: #
S1: ####
S2: ######
S3: #######
S4: ########이런 식으로 이어지며, S0이 첫 번째 샘플, S1이 두 번째 샘플 … 식이 됩니다.
각 샘플의 지속 시간은 샘플링 주파수에 따라 달라집니다. 즉, 1초에 몇 번 오디오 파형을 바꾸는지(재생 시) 혹은 샘플링하는지(녹음 시)에 따라 정해집니다. 따라서 복잡한 소리를 재생하려면 Pico 핀이 서로 다른 전압을 출력할 수 있어야 합니다.
Pico에서 PWM만으로 이를 구현하는 트릭이 있습니다. 아주 높은 주파수의 구형파를 사용하되, 만들고 싶은 전압에 따라 듀티 사이클을 다르게 설정하는 것입니다. 그래서 아주아주 높은 주파수로 출력을 설정합니다:
pwm.freq(100000)그런 다음 S0 샘플을 만들고 싶다면 듀티 사이클(값 범위는 0부터 65535까지입니다)을 작은 값으로 설정합니다. S1 샘플을 만들고 싶다면 더 큰 값을 사용하는 식입니다. 순서대로라면 다음과 같이 할 수 있습니다:
pwm.duty_u16(3000) # S0
pwm.duty_u16(12000) # S1
pwm.duty_u16(18000) # S2
pwm.duty_u16(21000) # S3
pwm.duty_u16(24000) # S4듀티 사이클은 핀이 1로 유지되는 시간과 0으로 유지되는 시간의 비율을 말합니다. 듀티 사이클이 65535이면 100% 동안 핀이 하이 상태이고, 0이면 항상 로우 상태입니다. 이 모든 것은 설정된 교번 주파수를 유지한 채로 이루어집니다. 오실로스코프로 확대해서 본다고 생각하면, S2와 S3 샘플을 생성하는 동안 어떤 일이 일어나는지 볼 수 있습니다:
S2:
######################
#
#
#
#
######################
#
#
#
#반면 S3은 다음과 같이 됩니다:
######################
######################
#
#
#
######################
######################
#
#
#핀은 동일한 주파수로 하이와 로우를 오가지만, S3의 경우 하이 상태로 더 오래 머무릅니다. 이렇게 하면 평균 전압이 더 높아집니다. 이 방식으로 파형을 근사할 수 있습니다.
WAV 파일 변환과 재생
wav 파일을 재생하려면 MicroPython으로 쉽게 읽을 수 있는 raw 포맷으로 변환해야 합니다. 저는 SoundCloud에서 “Oh no!”라고 말하는 wav 파일을 내려받았습니다. 그래서 변환 과정은 이렇게 됩니다:
ffmpeg -i ohno.wav -ar 24000 -acodec pcm_u8 -f u8 output.raw파일을 8비트 오디오(샘플당 256단계의 출력 레벨)로 변환했다는 점에 유의해 주십시오. 어차피 PWM 트릭으로 다양한 레벨을 그렇게 정밀하게 근사할 수도 없고, 리소스도 제한적이니까요. 16비트로 시도해 볼 수도 있지만, 저는 이 정도로도 괜찮은 결과를 얻었습니다.
그 다음 mpremote를 이용해 output.raw 파일을 장치에 업로드합니다:
mpremote cp output.raw :이제 “play.py”라는 이름으로(원하는 이름으로 해도 됩니다) 다음 내용을 담은 파일을 작성합니다:
from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(100000)
f = open("output.raw","rb")
buf = bytearray(4096)
while f.readinto(buf) > 0:
for sample in buf:
pwm.duty_u16(sample<<8)
x=1
x=1
x=1
x=1
x=1
f.close()여기서 하는 일은 단순히 파일을 한 번에 4096샘플씩 읽어 들인 뒤, 샘플 값에 따라 서로 다른 PWM 듀티 사이클을 차례로 설정해 “재생”하는 것입니다. 문제는 PCM 파일이 초당 24000샘플로 되어 있다는 점입니다(ffmpeg 명령줄을 참고하십시오). 이것을 MicroPython의 속도에 어떻게 맞출 수 있을까요? 사실 완벽하게 일치하지는 않습니다. 그래서 피치가 대략 맞게 들릴 때까지 약간의 지연을 주려고 “x=1” 구문을 추가했습니다.
아, 그리고 sample<<8 부분이 무엇인지 궁금하시다면, 이는 8비트 샘플을 PWM 듀티 사이클 설정에 필요한 16비트 전체 정밀도로 다시 스케일링하기 위한 것입니다.
이 방식의 단점은 재생하는 동안 프로그램이 계속 점유된다는 점입니다. 아직 테스트해 보지는 않았지만, MicroPython이 스레딩을 지원하므로 오디오 재생을 별도 스레드로 돌리는 것이 방법이 될 수 있습니다.
보너스: 사인파 소리 생성
# Sin wave
wave=[]
wave_samples = 40
pwm.freq(100000)
for i in range(wave_samples):
x = i/wave_samples*3.14*2
dc = int((1+math.sin(x))*65000)
wave.append(dc)
print(wave)
for i in range(1000):
for dc in wave: pwm.duty_u16(dc)글을 무작위로 읽기