라즈베리파이 피코와 PCM5101A로 오디오 재생하기 프로젝트

Posted on

라즈베리파이 피코는 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 버튼을 누를때 적절한 기능을 실행되게 할수도 있습니다. 이 글에서는 일단 최소 코드만 보여봅니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다