라즈베리파이 피코는 Cortex-M0+ 듀얼코어를 장착한 MCU 개발보드로 264KB의 램을 제공합니다. 피코에 PCM5101A를 장착하면 PCM 신호를 보내 고음질의 음악을 연주할 수 있습니다. MP3는 따로 PCM으로 변환해야 하고, 인터페이스는 I2S여야 합니다.
라즈베리파이 피코는 I2S 핀을 따로 제공하지 않으므로 PIO 상태기계를 활용해서 I2S를 에뮬레이션해야 하구요. 마침 라즈베리파이 재단에서 제공하는 pico-extras 프로젝트 하위 프로젝트인 pico_audio_i2s를 활용하면 기정의된 PIO로 만들어진 C 라이브러리가 있어 편리하게 쓰는게 가능해집니다.
하드웨어 설비를 간편하게 하기 위한 최소 설비입니다:
라즈베리파이 피코 한대
Waveshare Pico-Audio rev.1
Wavashare expander quad
SPI microSD 카드 모듈
FAT32로 포맷된 microSD 카드
micro-USB 케이블 두개
이중에서 expander quad라고 된 제품은 일종의 확장보드 성격의 작은 보드로 4개의 모듈을 핀을 직접 연결하지 않고도 쓰게 되어 있는 쉴드입니다. 특별히 좋은 것은 3V3 핀을 4개로 나누어서 전원 공급이 되게 해주니 좋습니다.
일반 설비를 잘 하고나서 프로젝트를 만듭니다.
아래와 같은 명령어를 씁니다.
# cd ~
user# git clone https://github.com/raspberrypi/pico-sdk
user# cd pico-sdk
user/pico-sdk# git submodule update --init --recursive
user/pico-sdk# cd ~
user# git clone https://github.com/raspberrypi/pico-extras
user# cd pico-extras
user/pico-extras# git submodule update --init -recursive
user/pico-extras# cd ~
user# git clone https://github.com/raspberrypi/pico-playground
user# cd pico-playground
user/pico-playground# git submodule update --init --recursive
user/pico-playground# cd ~
user# git clone https://github.com/raspberrypi/pico-examples
user# cd pico-extras
user/pico-examples# git submodule update --init -recursive
user# mkdir pico-audio
user# cd pico-audio
user/pico-audio# git clone https://github.com/elehobica/pico_fatfs.git
이런 명령어로 피코 C SDK와 하위 예제 프로젝트를 받아오고 환경변수를 등록합니다. 그리고 프로젝트 디렉토리를 하나 만들어서 pico_fatfs를 받아오고 거기서 작업합니다.
제일 중요한 것이 pico_audio_i2s를 쓰는 방법입니다.
이 하위 프로젝트에는 PIOASM이 정의된 파일이 포함되어 있고, I2S를 작동시킬때 필요한 C 코드들이 쓸만하게 정의되어 있습니다. PIOASM은 아래와 같습니다.
.program audio_i2s
.side_set 2
; /--- LRCLK
; |/-- BCLK
bitloop1: ; ||
out pins, 1 side 0b10
jmp x-- bitloop1 side 0b11
out pins, 1 side 0b00
set x, 14 side 0b01
bitloop0:
out pins, 1 side 0b00
jmp x-- bitloop0 side 0b01
out pins, 1 side 0b10
public entry_point:
set x, 14 side 0b11
.program audio_i2s_swapped
.side_set 2
; /--- BCLK
; |/-- LRCLK
bitloop1: ; ||
out pins, 1 side 0b01
jmp x-- bitloop1 side 0b11
out pins, 1 side 0b00
set x, 14 side 0b10
bitloop0:
out pins, 1 side 0b00
jmp x-- bitloop0 side 0b10
out pins, 1 side 0b01
public entry_point:
set x, 14 side 0b11
% c-sdk {
static inline void audio_i2s_program_init(PIO pio, uint sm, uint offset, uint data_pin, uint clock_pin_base) {
pio_sm_config sm_config = audio_i2s_program_get_default_config(offset);
sm_config_set_out_pins(&sm_config, data_pin, 1);
sm_config_set_sideset_pins(&sm_config, clock_pin_base);
sm_config_set_out_shift(&sm_config, false, true, 32);
sm_config_set_fifo_join(&sm_config, PIO_FIFO_JOIN_TX);
pio_sm_init(pio, sm, offset, &sm_config);
#if PICO_PIO_USE_GPIO_BASE
uint64_t pin_mask = (1ull << data_pin) | (3ull << clock_pin_base);
pio_sm_set_pindirs_with_mask64(pio, sm, pin_mask, pin_mask);
#else
uint32_t pin_mask = (1u << data_pin) | (3u << clock_pin_base);
pio_sm_set_pindirs_with_mask(pio, sm, pin_mask, pin_mask);
#endif
pio_sm_set_pins(pio, sm, 0); // clear pins
pio_sm_exec(pio, sm, pio_encode_jmp(offset + audio_i2s_offset_entry_point));
}
%}
보시듯이 I2S에 대응되는 통신을 위해 핀을 할당하고 신호 타이밍에 맞추어 데이터를 보내게 되어 있는 구조입니다. 특별히 어려울 코드는 없으며 각각 DIN과 BCK에 대응되는 data_pin과 clock_pin을 지정하면 되는데요. LRCK는 clock_pin 바로 다음 핀으로 자동으로 잡합니다. MCLK는 따로 핀이 바깥으로 빠져나온 모듈이면 GND에 꽂으면 되는데요. Pico-Audio rev.1은 MCLK가 따로 핀으로 없고, BCK로부터 나온 클럭을 내부 PLL로 가공해서 자동으로 신호 타이밍을 맞추어주니 큰 신경을 쓸 필요가 없어집니다.
Pico-Audio 위키페이지에 가보면 아래와 같은 핀을 쓰라고 되어 있습니다.
#define I2S_DATA_PIN 26
#define I2S_BCLK_PIN 27
#define I2S_LRCK_PIN 28
이는 C 코드에서 잘 지정해주면 expander quad에서 알아서 신호를 보내주니 잘 지정해두기만 하면 됩니다.
I2S 설비가 다 되었으면 이번에는 pico_audio_i2s의 사용법을 살펴보는 것이 할 일입니다. pico_audio_i2s는 아래 단계를 거쳐서 초기화하고 재생을 합니다.
• 3개의 PIO 중에 하나를 골라 설정
• DMA로 하나를 골라 설정
• I2S 구조체, 오디오 형식 구조체, 오디오 버퍼 구조체를 설정하고
• 프로듀서, 컨슈머 풀 가동
• microSD 카드에서 WAV 파일 읽어오기
• 읽어온 내용을 버퍼에 보내서 재생
이 과정을 거칩니다.
PIO 설정과 DMA 설정, 프로듀서, 컨슈머 풀 가동은 아래와 같은 형식으로 합니다.
#define SAMPLE_RATE 44100
#define BUFFER_SAMPLES 1024
#define I2S_DATA_PIN 26
#define I2S_BCLK_PIN 27
#define I2S_LRCK_PIN 28
int audio_sm = pio_claim_unused_sm(pio0, true); // 사용안하고 있는 상태기계 자동 선택
int audio_dma = dma_claim_unused_channel(true); // 사용안하고 있는 DMA 채널 자동 선택
pio_sm_unclaim(pio0, audio_sm); // 옵셔널: 위 코드가 작동안하면 즉시 해제 (경합이 일어날 수 있으니 위 두코드가 안될때만 가동)
dma_channel_unclaim(audio_dma); // 옵셔널: 위와 같음
// I2S 설비 정보
struct audio_i2s_config i2s_config = {
.data_pin = I2S_DATA_PIN,
.clock_pin_base = I2S_BCLK_PIN,
.dma_channel = audio_dma,
.pio_sm = audio_sm,
};
// 재생할 WAV 파일 사양과 일치하게 설정
struct audio_format audio_format = {
.format = AUDIO_BUFFER_FORMAT_PCM_S16,
.sample_freq = SAMPLE_RATE,
.channel_count = 2,
};
// 16비트 signed WAV의 경우 sample_stride를 4 추천
struct audio_buffer_format producer_format = {
.format = &audio_format,
.sample_stride = 4
};
// 프로듀서, 컨슈머 풀 구성
struct audio_buffer_pool *producer_pool = audio_new_producer_pool(&producer_format, 3, BUFFER_SAMPLES);
if (!producer_pool) {
printf("[Core 1] FATAL: Failed to create audio pool\n"); // 로그 추가
panic("Failed to create audio pool");
}
// 설정한대로 pico_audio_i2s 시동 준비 완료
audio_i2s_setup(&audio_format, &i2s_config);
audio_i2s_connect(producer_pool);
audio_i2s_set_enabled(true);
이렇게 해두고 FatFS으로 파일을 읽어오는 코드와 연동합니다.
대략 좋은 위치에
#define SPI_PORT spi1
#define PIN_MISO 12 // GPIO12 = SPI1_RX (SD카드 MISO → Pico 입력)
#define PIN_MOSI 11 // GPIO11 = SPI1_TX (SD카드 MOSI ← Pico 출력)
#define PIN_SCK 10 // GPIO10 = SPI1_SCK
#define PIN_CS 13
#define WAV_FILENAME "bgm1.wav"
#define WAV_HEADER_SIZE 44
printf("[Core 0] Configuring SPI for SD Card...\n");
pico_fatfs_spi_config_t spi_config = {
SPI_PORT, 400000, 15000000,
PIN_MISO, PIN_CS, PIN_SCK, PIN_MOSI,
true}; // MISO 내부 풀업 필수 (tf_card.c 주석: "pull up of MISO is MUST")
pico_fatfs_set_config(&spi_config);
FATFS fs;
printf("[Core 0] Attempting to mount SD Card...\n");
FRESULT mount_res = f_mount(&fs, "", 1);
if (mount_res != FR_OK)
{
printf("[Core 0] FATAL: SD Card Mount Failed! FatFS Error Code: %d\n", mount_res);
panic("SD Card Mount Failed\r\n");
}
printf("[Core 0] SUCCESS: SD Card Mounted.\n");
마운트가 잘 되어 있으면 아래와 같은 코드를 실행합니다.
while (true)
{
FIL fil;
printf("[Core 1] Attempting to open file: bgm1.wav\n"); // ⭐️ 로그 추가
mutex_enter_blocking(&sd_card_mutex);
FRESULT fr_open = f_open(&fil, "bgm1.wav", FA_READ);
mutex_exit(&sd_card_mutex);
if (fr_open != FR_OK) {
printf("[Core 1] ERROR: Failed to open bgm1.wav! FatFS Error Code: %d\n", fr_open); // 실패 원인 파악
sleep_ms(2000); // 실패 시 2초 대기 후 재시도
continue;
}
printf("[Core 1] SUCCESS: File opened.\n"); // 로그 추가
g_played_audio_bytes = 0;
g_playback_finished = false;
g_is_playing = true;
g_file_change_request = false;
struct audio_buffer *buffer = take_audio_buffer(producer_pool, true);
if (buffer)
{
memset(buffer->buffer->bytes, 0, buffer->buffer->size);
buffer->sample_count = buffer->max_sample_count;
give_audio_buffer(producer_pool, buffer);
}
uint32_t loop_counter = 0; // 로그 출력 빈도 조절용 변수
// 오디오 재생 무한 루프
while (true)
{
buffer = take_audio_buffer(producer_pool, true);
UINT bytes_read;
mutex_enter_blocking(&sd_card_mutex);
FRESULT fr = f_read(&fil, buffer->buffer->bytes, buffer->buffer->size, &bytes_read);
mutex_exit(&sd_card_mutex);
if (fr != FR_OK || bytes_read == 0)
{
printf("\n[Core 1] Playback finished or Read Error. FR: %d, Bytes read: %d\n", fr, bytes_read); // ⭐️ 로그 추가
g_playback_finished = true;
release_audio_buffer(producer_pool, buffer);
printf("[Core 1] Closing file.\n");
f_close(&fil);
break;
}
else
{
g_played_audio_bytes += bytes_read;
buffer->sample_count = bytes_read / 4;
give_audio_buffer(producer_pool, buffer);
// 과도한 printf로 인한 끊김 방지: 버퍼 1000번 채울 때마다 한 번씩만 출력
loop_counter++;
if (loop_counter % 1000 == 0) {
printf("[Core 1] Playing... (Played %d bytes)\n", g_played_audio_bytes);
}
}
sleep_ms(1);
}
}
audio_i2s_set_enabled(false);
요점은 f_open으로 WAV 파일을 열고, f_read로 WAV 파일을 포인터를 옮겨가면서 조금씩 읽어와서 take_audio_buffer와 give_audio_buffer로 pico_audio_i2s가 소비할 버퍼를 채운다는 것입니다. 재생할 버퍼가 다 소진되고 파일의 끝에 포인터가 가면 release_audio_buffer를 실행해서 버퍼를 비우고 파일을 닫습니다. 이 단계에서 안쪽 while 루프를 빠져나와 처음부터 같은 파일을 다시 연주합니다.
이를 응용하면 LVGL로 오디오 재생기 UI를 표시하고 각각의 UI 버튼을 누를때 적절한 기능을 실행되게 할수도 있습니다. 이 글에서는 일단 최소 코드만 보여봅니다.