Using I2S DAC with Sipeed Longan Nano

Monday, November 9, 2020

I2S DAC Longan Nano

t f B! P L

Using ES9023 as an External DAC

I modified a Covia ZEAL EDGE ZDC-205A-SG, originally sold as a USB DAC, for use in this experiment. It uses an ESS Technology ES9023 as its 24-bit DAC and also features a built-in headphone amplifier, making it compact yet high quality.
Covia Zeal Edge converted to I2S DAC

In the photo above, the 16-pin IC located between the QFP and the headphone jack is the ES9023.


ES9023 I2S-Related Pins

Pin No.Pin NameNotes
1BCKLift pin and connect to Longan Nano I2S2_CK
2LRCKLift pin and connect to Longan Nano I2S2_WS
3SDILift pin and connect to Longan Nano I2S2_SD
13MCLKAlready supplied on the board; leave as is

I2S Pin Configuration

Longan Nano I2S-Related Pins
On the Sipeed Longan Nano, SPI and I2S share the same pins. Among SPI0 through SPI2, I2S can be used with SPI1 and SPI2. However, since SPI1 is assigned to the microSD card on the board, if you want to keep the microSD card functional, you need to use SPI2 (I2S2) for the I2S DAC.

Pin NameI2S1I2S2 (used here)
I2S_CK (=SPI_SCK)PB13PB3 (JTDO)
I2S_WS (=SPI_NSS)PB12PA15 (JTDI)
I2S_SD (=SPI_MOSI)PB15PB5


I2S2 Pin Setup

Before configuring each block, the clock must be enabled. The following sets up pins PA15, PB3, and PB5 as Alternate Function (I2S2) instead of GPIO. PAxx pins belong to the GPIOA block and PBxx pins belong to the GPIOB block, each requiring separate configuration.

    rcu_periph_clock_enable(RCU_AF);
    rcu_periph_clock_enable(RCU_GPIOA);
    rcu_periph_clock_enable(RCU_GPIOB);

    gpio_pin_remap_config(GPIO_SWJ_DISABLE_REMAP, ENABLE);
    gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_15);
    gpio_init(GPIOB, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_3 | GPIO_PIN_5);

I2S Initialization

I2S_STD_PHILLIPS is specified as the I2S format. Regarding the I2S_CK polarity, setting it to I2S_CKPL_LOW caused unwanted edges on I2S_SD before transmission started, so I used I2S_CKPL_HIGH instead.

I2S_FRAMEFORMAT_DT24B_CH32B configures 32-bit data per channel, outputting the effective lower 24 bits starting from the MSB. I2S_AUDIOSAMPLE_44K specifies the sampling frequency.

    rcu_periph_clock_enable(RCU_SPI2);

    i2s_init(SPI2, I2S_MODE_MASTERTX, I2S_STD_PHILLIPS, I2S_CKPL_HIGH);
    i2s_psc_config(SPI2, I2S_AUDIOSAMPLE_44K, I2S_FRAMEFORMAT_DT24B_CH32B, I2S_MCKOUT_DISABLE);
    i2s_enable(SPI2);
I2S Clock Path (excerpt from documentation)

These settings also determine the I2S_CK frequency. 

  • I2S_CK frequency = 108 (MHz) / 38 = 2.842 (MHz)
  • Sampling frequency = 108 (MHz) / (64 x 38) = 44.41 (KHz)

However, the error between 44.41 KHz and the target 44.1 KHz is too large, so this configuration is problematic as is.

To achieve a more accurate sampling frequency, the clock path is revised as follows. Instead of 108 MHz, a 96 MHz clock is supplied to the I2S block. In this case, if the divider ratio within the I2S block is set to 34:

  • I2S_CK frequency = 96 (MHz) / 34 = 2.824 (MHz)
  • Sampling frequency = 96 (MHz) / (64 x 34) = 44.118 (KHz)

which should give us the desired result.

I2S Clock Path Revised (excerpt from documentation)

The additional settings required for this are as follows:
    RCU_CTL &= ~(RCU_CTL_PLL1EN | RCU_CTL_PLL2EN);
    rcu_predv1_config(RCU_PREDV1_DIV2);
    rcu_pll2_config(RCU_PLL2_MUL12);
    rcu_i2s2_clock_config(RCU_I2S2SRC_CKPLL2_MUL2);
    RCU_CTL |= (RCU_CTL_PLL1EN | RCU_CTL_PLL2EN);

DMA Data Transfer

DMA Initialization
Data to be transmitted is transferred to the SPI_DATA register via DMA. The key points for setting up the DMA transfer are:
  • Memory-to-peripheral transfer
  • Peripheral address is fixed: SPI_DATA(SPI2)
  • Memory side uses 16-bit transfer, peripheral side uses 32-bit transfer
  • DMA1 CH1 is used
void init_dma_i2s2(uint32_t memory_addr, uint32_t trans_number)
{
    rcu_periph_clock_enable(RCU_DMA1);

    dma_struct_para_init(&dma_param);
    dma_param.periph_addr = &SPI_DATA(SPI2);
    dma_param.periph_width = DMA_PERIPHERAL_WIDTH_32BIT;
    dma_param.periph_inc = DMA_PERIPH_INCREASE_DISABLE;
    dma_param.memory_addr = memory_addr;
    dma_param.memory_width = DMA_MEMORY_WIDTH_16BIT;
    dma_param.memory_inc = DMA_MEMORY_INCREASE_ENABLE;
    dma_param.direction = DMA_MEMORY_TO_PERIPHERAL;
    dma_param.number = trans_number;
    dma_param.priority = DMA_PRIORITY_HIGH;
    dma_init(DMA1, DMA_CH1, &dma_param);
}
DMA Transfer
The I2S2 initialization, DMA initialization, and subsequent transfer operations can be summarized as follows. Two arrays, audio_buf0 and audio_buf1, are prepared as double buffers for the source data.
    #define SIZE_OF_SAMPLES 512  // samples for 2ch

    int32_t audio_buf0[SIZE_OF_SAMPLES];
    int32_t audio_buf1[SIZE_OF_SAMPLES];

    init_i2s2();
    init_dma_i2s2(audio_buf0, SIZE_OF_SAMPLES*2);

    spi_dma_enable(SPI2, SPI_DMA_TRANSMIT);
    dma_channel_enable(DMA1, DMA_CH1);

    int count = 0;
    while (1) {
        if (SET == dma_flag_get(DMA1, DMA_CH1, DMA_FLAG_FTF)) {
            dma_flag_clear(DMA1, DMA_CH1, DMA_FLAG_FTF);
            dma_channel_disable(DMA1, DMA_CH1);
            if (count % 2 == 0) {
                init_dma_i2s2(audio_buf1, SIZE_OF_SAMPLES*2);
            } else {
                init_dma_i2s2(audio_buf0, SIZE_OF_SAMPLES*2);
            }
            dma_channel_enable(DMA1, DMA_CH1);

            // Prepare the next audio_buf here

            count++;
        }
    }
I2S Data Layout

At this point, data can be continuously transferred to the I2S data register via DMA. However, when examining the relationship between the memory data layout and the actual I2S output data order using a logic analyzer, I found the following behavior (little-endian):

Relationship between DMA transfer data and I2S data

Since sample data is 24 bits per channel, you would normally place the L data at address 0 and R data at address 4, with each aligned either MSB-justified (D, C, B) or LSB-justified (C, B, A) within 32 bits. 
However, the actual I2S output starts with the MSB of address 0 in 16-bit units,
followed by the upper 8 bits of address 2, with the lower 8 bits output as zeros. 
This means that with a standard data layout, the I2S output appears as if the data is swapped every 16 bits. Therefore, the data itself must be generated with 16-bit halves swapped.

Code Example

Based on the above, here is a code example that generates a triangle wave at an arbitrary frequency.
The VS Code PlatformIO project is available here.

#include "gd32vf103_rcu.h"
#include "gd32vf103_gpio.h"
#include "gd32vf103_spi.h"
#include "gd32vf103_dma.h"

#define SIZE_OF_SAMPLES 512  // samples for 2ch
#define SAMPLE_RATE     (44100)
#define WAVE_FREQ_HZ    (440)
#define PI              (3.14159265)
#define SAMPLE_PER_CYCLE (SAMPLE_RATE/WAVE_FREQ_HZ)
#define DELTA 2.0*PI*WAVE_FREQ_HZ/SAMPLE_RATE

int32_t audio_buf0[SIZE_OF_SAMPLES];
int32_t audio_buf1[SIZE_OF_SAMPLES];

static double ang = 0;
static int count = 0;
static double triangle_float = 0.0;

union U {
    uint32_t i;
    uint16_t s[2];
} u;

// 16-bit swap
uint32_t swap16b(uint32_t in_val)
{
    u.i = in_val;
    return ((uint32_t) u.s[0] << 16) | ((uint32_t) u.s[1]);
}

double _square_wave(void)
{
    double dval;
    if (ang >= 2.0*PI) {
        ang -= 2.0*PI;
        triangle_float = -(double) pow(2, 22);
    }
    if (ang < PI) {
        dval = 1.0;
    } else {
        dval = -1.0;
    }
    return dval;
}

// Triangle wave generation
void setup_triangle_sine_waves(int32_t *samples_data)
{
    unsigned int i;
    double square_float;
    double triangle_step = (double) pow(2, 23) / SAMPLE_PER_CYCLE;

    for(i = 0; i < SIZE_OF_SAMPLES/2; i++) {
        square_float = _square_wave();
        ang += DELTA;
        if (square_float >= 0) {
            triangle_float += triangle_step;
        } else {
            triangle_float -= triangle_step;
        }

        square_float *= (pow(2, 23) - 1);
        samples_data[i*2+0] = swap16b((int) square_float * 256);
        samples_data[i*2+1] = swap16b((int) triangle_float * 256);
    }
}

void prepare_audio_buf(void)
{
    setup_triangle_sine_waves(audio_buf0);
    setup_triangle_sine_waves(audio_buf1);

    init_i2s2();
    init_dma_i2s2(audio_buf0, SIZE_OF_SAMPLES*2);

    spi_dma_enable(SPI2, SPI_DMA_TRANSMIT);
    dma_channel_enable(DMA1, DMA_CH1);
    count = 0;
}

void run_audio_buf(void)
{
    if (SET == dma_flag_get(DMA1, DMA_CH1, DMA_FLAG_FTF)) {
        dma_flag_clear(DMA1, DMA_CH1, DMA_FLAG_FTF);
        dma_channel_disable(DMA1, DMA_CH1);
        if (count % 2 == 0) {
            init_dma_i2s2(audio_buf1, SIZE_OF_SAMPLES*2);
        } else {
            init_dma_i2s2(audio_buf0, SIZE_OF_SAMPLES*2);
        }
        dma_channel_enable(DMA1, DMA_CH1);
        if (count % 2 == 0) {
           setup_triangle_sine_waves(audio_buf0);
        } else {
           setup_triangle_sine_waves(audio_buf1);
        }
        count++;
    }
}

dma_parameter_struct dma_param;

void init_dma_i2s2(uint32_t memory_addr, uint32_t trans_number)
{
    rcu_periph_clock_enable(RCU_DMA1);

    dma_struct_para_init(&dma_param);
    dma_param.periph_addr = &SPI_DATA(SPI2);
    dma_param.periph_width = DMA_PERIPHERAL_WIDTH_32BIT;
    dma_param.periph_inc = DMA_PERIPH_INCREASE_DISABLE;
    dma_param.memory_addr = memory_addr;
    dma_param.memory_width = DMA_MEMORY_WIDTH_16BIT;
    dma_param.memory_inc = DMA_MEMORY_INCREASE_ENABLE;
    dma_param.direction = DMA_MEMORY_TO_PERIPHERAL;
    dma_param.number = trans_number;
    dma_param.priority = DMA_PRIORITY_HIGH;
    dma_init(DMA1, DMA_CH1, &dma_param);
}

void init_i2s2(void)
{
    rcu_periph_clock_enable(RCU_AF);
    rcu_periph_clock_enable(RCU_GPIOA);
    rcu_periph_clock_enable(RCU_GPIOB);
    rcu_periph_clock_enable(RCU_SPI2);

    RCU_CTL &= ~(RCU_CTL_PLL1EN | RCU_CTL_PLL2EN);
    rcu_predv1_config(RCU_PREDV1_DIV2);
    rcu_pll2_config(RCU_PLL2_MUL12);
    rcu_i2s2_clock_config(RCU_I2S2SRC_CKPLL2_MUL2);
    RCU_CTL |= (RCU_CTL_PLL1EN | RCU_CTL_PLL2EN);

    gpio_pin_remap_config(GPIO_SWJ_DISABLE_REMAP, ENABLE);
    gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_15);
    gpio_init(GPIOB, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_3 | GPIO_PIN_5);    

    i2s_init(SPI2, I2S_MODE_MASTERTX, I2S_STD_PHILLIPS, I2S_CKPL_HIGH);
    i2s_psc_config(SPI2, I2S_AUDIOSAMPLE_44K, I2S_FRAMEFORMAT_DT24B_CH32B, I2S_MCKOUT_DISABLE);
    i2s_enable(SPI2);
}

int main(void)
{
    rcu_periph_clock_enable(RCU_GPIOA);
    rcu_periph_clock_enable(RCU_GPIOC);
    gpio_init(GPIOC, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_13);
    gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_1|GPIO_PIN_2);

    prepare_audio_buf();

    while (1) {
        run_audio_buf();
    }
    return 0;
}

About Me

My photo
Electronics, programming & audio

Featured Post

Synchronizing Radio-Controlled Clocks with Raspberry Pi Pico W (JJY Standard Radio Wave Emulator)

As a Raspberry Pi Pico W application, I built a JJY emulator for radio-controlled clocks (for time synchronization) with minimal peripheral...

QooQ