DAC 없이 Pi Pico에서 오디오 파일 재생하기
원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기
라즈베리 파이 피코는 어느새 임베디드 개발에서 내가 가장 선호하는 칩이 되었다. 잘 만들어지고 내구성 좋은 하드웨어에, 영리함과 열정으로 설계된 듯한 기능이 가득하다(GPIO를 구동하는 스테이트 머신은 정말 킬러 기능이다!). 가장 큰 약점이던 연결성 부족은 이제 W 모델로 해결됐다. 데이터시트는 훌륭해서 칩의 모든 면을 문서화하고 있다. 게다가 MicroPython 지원도 잘 되고(나는 정말 많이 쓰고 있다), C SDK 환경도 괜찮은 편이다. 물론 요즘 유행이 요구하는 대로 쓸데없는 복잡성으로 가득하긴 하지만 말이다. Makefile을 생성하는 cmake 빌드 시스템, 이거저거를 정의하는 파일들(사용 라이브러리, 디버그 출력 등등), 그리고 일반적으로 작은 장치를 위한 작은 프로그램을 컴파일한다는 목표에 비하면 터무니없이 과도한 것들 말이다. 아니, 그보다 더 심하다. 고정된 기능 세트를 가진 고정된 하드웨어(W/non-W 모델 구분을 제외하면)를 위한 프로그램을 만들기 위해 이 모든 복잡성을 감수해야 한다니. 오늘날 소프트웨어가 얼마나 엉망인지에 대한 푸념은 이쯤 해두자. 하지만 꼭 기억해 둬야 할 일이다.
이런 MCU로 하고 싶은 멋진 일 중 하나는 소리를 내는 것이다. 이를 위한 가장 확실한 방법은 칩에 내장된 PWM 기능을 이용하는 것이다. GPIO는 원하는 주파수로 0과 1 사이를 단순히 오가도록 설정할 수 있다. 예를 들면 이렇게 말이다.
from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(400)
pwm.duty_u16(1000)피에조 부저를 피코의 GND와 1번 핀에 연결했다고 가정하면, 400Hz 주파수의 구형파 소리가 들릴 것이다. 그런데 구형파만큼 듣기 거북한 소리도 별로 없다. 좀 더 나은 방법을 찾아보자. 여기서는 사인파 만들기 같은 중간 단계는 모두 건너뛰고 바로 wav 파일을 재생하는 방법으로 넘어가겠다. 이 방법을 이해하면 다른 파형(사인파, 노이즈, 이런 파형들을 위한 엔벨로프 등)도 쉽게 직접 만들 수 있다.
이제 아마 이런 생각이 들 것이다. 피코는 핀을 하이(high)나 로우(low)로만 전환할 수 있는데, 어떻게 wav 파일을 재생할 복잡한 파형을 만들 수 있지? 제대로 된 비구형파는 서로 다른 레벨들로 이루어져 있으니 DAC가 필요하지 않나? 다행히도 DAC 없이도, 피코의 핀 하나만으로 이 모든 걸 해낼 수 있다.
복잡한 소리 생성이 동작하는 방식
여기서 배경지식을 너무 많이 다루고 싶지는 않다. 다만 알아둬야 할 것은, 출력의 최소 레벨과 최대 레벨 사이를 단순히 오가는 평범한 구형파가 아니라면, 중간 단계가 필요하다는 것이다. 예를 들면 이런 식이다.
S0: #
S1: ####
S2: ######
S3: #######
S4: ########이런 식으로 이어지는데, 여기서 S0은 첫 번째 샘플, S1은 두 번째 샘플이다…
각 샘플의 지속 시간은 샘플링 주파수에 따라 달라진다. 샘플링 주파수란 1초마다 오디오 파형을 몇 번 바꾸는지(재생 시) 혹은 샘플링하는지(녹음 시)를 의미한다. 즉 복잡한 소리를 재생하려면 피코 핀이 서로 다른 전압을 출력할 수 있어야 한다는 뜻이다.
피코에서 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듀티 사이클이란 핀이 0인 시간 대비 1로 설정되어 있는 시간의 비율이다. 듀티 사이클이 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)글을 무작위로 읽기
댓글
로그인하고 댓글 남기기