MP3 Playback with TinyGo (VS1053)

Saturday, June 4, 2022

Raspberry Pi Pico TinyGo

t f B! P L

I created a sample project for MP3 playback with TinyGo using Raspberry Pi Pico and VS1053.

Raspberry Pi Pico + VS1053 Test Setup

Overview

Using SD Cards with TinyGo (Speed Improvement Edition), VS1053 control for the MP3 playback LSI is added. VS1053 control is ported directly from the basic parts of the Adafruit_VS1053_Library to TinyGo. However, since the part where bitstream reading and writing is performed within the interrupt routine would impose constraints when adding other features, this part was changed to use Go language multithreading functionality.

VS1053 Control

The control method for VS1053, the MP3 decoder LSI, is as follows.

  • Commands to VS1053 are issued via SCI through the SPI interface
  • The rising edge of VS1053's DREQ pin is detected via interrupt, and the MP3 bitstream is written via SDI through the SPI interface

The same LSI has separate CS pins for SCI and SDI, sharing the SPI interface. In this project, SD card SPI access is also added, resulting in a configuration where three SPI access systems share a single SPI interface. Additionally, once MP3 playback starts, DREQ interrupts requesting data must be handled asynchronously and written to a buffer.

Go Features Used

The following TinyGo multithreading-related features are utilized.

  • Using chan to send events from the interrupt routine to a sub-thread
  • Using goroutine for MP3 bitstream reading and writing in a sub-thread
  • Using Mutex for SPI mutual exclusion (SPI shared between VS1053 and SD card)

The interrupt routine only sends an event notifying that an interrupt occurred, and the sub-thread launched by goroutine receives the event and performs data reading from the SD card and data writing to the MP3 LSI. This way, the main thread handles various settings and command processing for the MP3 LSI, while the sub-thread handles SD card reading and writing to the MP3 LSI, sharing the single SPI interface resource with mutual exclusion. This type of processing itself is no different from coding in C++, but the theme of this project is to try how such processing can be written in TinyGo using an actual project.

Wiring Diagram

The wiring diagram is shown below. The SPI signals share SPI1 between the SD card and VS1053, but they can be assigned to a different SPI by changing the project settings.

Wiring Diagram

Code Description

Now, let's explain the code of the pico_tinygo_vs1053 project.

chan

First, a chan called mp3BufReq is prepared in the struct used by the MP3 player's main. Since this project only needs to perform event notification, the chan data itself is an empty struct{}.


pico_tinygo_vs1053/vs1053/vs1053_file_player.go

type Player struct {
    codec        *Device
    isPlaying    bool
    isPaused     bool
    currentTrack File
    mp3Buf       []byte
    mp3BufReq    chan struct{}
}

The chan initialization is performed at the timing when MP3 file playback starts. make() is used to initialize the chan. Setting the chan size larger than 1 enables buffering, but this project uses 1 for REQ_CH_SZ (also for testing purposes). When the size is 1, the next event cannot be sent unless the transmitted event has been reliably received.
Next, the DREQ interrupt handler is configured. Here, an anonymous function that only sends events to mp3BufReq is passed to setDreqInterrupt().


pico_tinygo_vs1053/vs1053/vs1053_file_player.go

func (p *Player) StartPlayingFile(file File) error {
	...
    p.mp3BufReq = make(chan struct{}, REQ_CH_SZ)
    p.codec.setDreqInterrupt(true, func() {
        p.mp3BufReq <- struct{}{} // send event (no type)
    })
    ...

Inside setDreqInterrupt(), it is configured to callback func() on the rising edge of the DREQ pin.


pico_tinygo_vs1053/vs1053/vs1053.go

func (d *Device) setDreqInterrupt(flg bool, f func()) error {
    if flg {
        if d.dreqPin == machine.NoPin {
            return fmt.Errorf("vs1053 failed to set interrupt")
        }
        d.dreqPin.SetInterrupt(machine.PinRising, func (pin machine.Pin) { f() })
    } else {
        d.dreqPin.SetInterrupt(machine.PinRising, nil)
    }
    return nil
}

goroutine

Now let's look at the sub-thread processing using goroutine. A considerable number of elements are concentrated in the first line of the code below.

  • Sub-thread definition using go functionName
  • An anonymous function is assigned to the sub-thread
  • A receive chan for empty data (struct{}) is attached to the sub-thread as req

Inside func(), data is retrieved from the chan at the _, more := <-req part. The important point is that when the chan is in an open state and nothing has been sent, execution blocks here. Therefore, when processing moves below this line, either there is data to be received in the chan or the chan has been closed, and this can be determined by more. The '_' to the left of more is originally a variable for the data received from the chan, but since we know empty data (struct{}) will arrive, it is discarded in this way.
When more is true, an event has been sent by the DREQ interrupt, so feedBuffer() is called to read the MP3 bitstream from the SD card and write it to VS1053's SDI.
When more is false, the chan has been closed, so return is called to terminate the sub-thread itself.
The (p.mp3BufReq) part at the bottom may be momentarily confusing if unfamiliar, but it is the actual variable assigned to the argument req of the anonymous function func.


pico_tinygo_vs1053/vs1053/vs1053_file_player.go

    go func(req <-chan struct{}) {
        for {
            _, more := <-req
            if !more {
                p.isPlaying = false
                p.isPaused = false
                p.codec.setDreqInterrupt(false, nil)
                return
            }
            p.feedBuffer()
        }
    } (p.mp3BufReq)

Above, the closing of the chan was associated with sub-thread termination. The part where the chan close actually occurs is shown below. Essentially, it is a simple process of closing the chan when there is no more bitstream to read from the MP3 file. In practice, the chan is also closed when the user stops playback mid-track. I found that the flow where closing the chan terminates the sub-thread on the goroutine side can be described very simply and rationally.


pico_tinygo_vs1053/vs1053/vs1053_file_player.go

func (p *Player) feedBuffer() {
    if !p.isPlaying || p.isPaused || !p.codec.readyForData() {
       return // paused or stopped
    }
 
    // Feed the hungry buffer! :)
    for p.codec.readyForData() {
        // Read some audio data from the SD card file
        br, err := p.currentTrack.Read(p.mp3Buf)
 
        if err == io.EOF {
            // must be at the end of the file, wrap it up!
            close(p.mp3BufReq)
            break
        }
 
        p.codec.playData(p.mp3Buf[:br])
    }
}

Mutex

Finally, let's look at the Mutex for exclusive SPI usage. Since three systems — SD card access, MP3 LSI SCI access, and SDI access — share a single SPI, the Mutex is placed on the resource-side SPI. Specifically, a mu variable is prepared as a pointer to sync.Mutex in the mymachine.SPI struct, and mu is initialized (&sync.Mutex{}) during SPI struct initialization.


pico_tinygo_vs1053/mymachine/machine_rp2040_spi.go

type SPI struct {
	*machine.SPI
	mu  *sync.Mutex
	brcm map[uint32]SPIBaudRateConfig
}
...
func NewSPI(spi *machine.SPI) (SPI) {
	return SPI{
		spi,
		&sync.Mutex{},
		make(map[uint32]SPIBaudRateConfig),
	}
}
 
func (spi SPI) Lock() {
	spi.mu.Lock()
}
 
func (spi SPI) Unlock() {
	spi.mu.Unlock()
}

As an example of the usage side, the SDI access of the MP3 LSI is shown below. Execution blocks at d.bus.Lock() until the resource is acquired. When moving to the next line, the SPI has been acquired, so defer the release d.bus.Unlock() and then use the SPI.


pico_tinygo_vs1053/vs1053/vs1053.go

func (d *Device) playData(buf []byte) {
    d.bus.Lock()
    defer d.bus.Unlock()
    d.dcsPin.Low()
    defer d.dcsPin.High()
    d.bus.SetBaudRate(FastFreq)
    d.bus.Tx(buf, nil)
}

Other

Parts not directly related to multithreading are also explained.


SPI as Interface

On the SPI consumer side, the dependency on the mymachine package was eliminated by defining SPI as an Interface.


pico_tinygo_vs1053/vs1053/vs1053.go

type SPI interface {
    Lock()
    Unlock()
    SetBaudRate(br uint32) error
    Transfer(w byte) (byte, error)
    Tx(w, r []byte) (err error)
}
 
type Device struct {
    bus        SPI
    csPin      machine.Pin
    rstPin     machine.Pin
    dcsPin     machine.Pin
    dreqPin    machine.Pin
}
 
const (
    SlowFreq = 1000000 // below 12.288 MHz * 3.0 / 7 (for SCI Read)
    FastFreq = 8000000 // below 12.288 MHz * 3.0 / 4 (for SCI Write and SDI Write)
)
 
func New(bus SPI, csPin, rstPin, dcsPin, dreqPin machine.Pin) Device {
    return Device{
        bus:        bus,
        csPin:      csPin,
        rstPin:     rstPin,
        dcsPin:     dcsPin,
        dreqPin:    dreqPin,
    }
}

Optimizing SetBaudRate()

The SPI SetBaudRate() method is a convenient method for Raspberry Pi Pico that sets registers from only the SCK frequency target value. However, calling it every time to switch to the appropriate SCK frequency for SD card access, SCI access, and SDI access as in this project resulted in considerable overhead, and MP3 playback could not keep up. Therefore, the once-calculated register settings are saved in a map keyed by the configured frequency, and from the second call onward, the already-calculated register values are directly applied for optimization.


pico_tinygo_vs1053/mymachine/machine_rp2040_spi.go

type SPI struct {
	*machine.SPI
	mu  *sync.Mutex
	brcm map[uint32]SPIBaudRateConfig
}
...
func (spi SPI) SetBaudRate(br uint32) error {
	var prescale, postdiv uint32
	brc, hasKey := spi.brcm[br]
	if !hasKey {
		// This takes time to calculate prescale and postdiv
		const freqin uint32 = 125 * machine.MHz
		const maxBaud uint32 = 66.5 * machine.MHz // max output frequency is 66.5MHz on rp2040. see Note page 527.
		// Find smallest prescale value which puts output frequency in range of
		// post-divide. Prescale is an even number from 2 to 254 inclusive.
		for prescale = 2; prescale < 255; prescale += 2 {
			if freqin < (prescale+2)*256*br {
				break
			}
		}
		if prescale > 254 || br > maxBaud {
			return ErrSPIBaud
		}
		// Find largest post-divide which makes output <= baudrate. Post-divide is
		// an integer in the range 1 to 256 inclusive.
		for postdiv = 256; postdiv > 1; postdiv-- {
			if freqin/(prescale*(postdiv-1)) > br {
				break
			}
		}
		// Register Key for speed-up at next time
		spi.brcm[br] = SPIBaudRateConfig {
			prescale,
			postdiv,
		}
	} else {
		// use calculated values
		prescale = brc.prescale
		postdiv  = brc.postdiv
	}
	spi.Bus.SSPCPSR.Set(prescale)
	spi.Bus.SSPCR0.ReplaceBits((postdiv-1)<<rp.SPI0_SSPCR0_SCR_Pos, rp.SPI0_SSPCR0_SCR_Msk, 0)
	return nil
}

SPI Access Waveform

The logic analyzer waveform of SPI access is shown below.

Overall View

In the overall waveform diagram, attention should be paid to the DREQ High period. Bitstream reading and writing are performed during the DREQ High period, and since sufficient DREQ Low period is ensured, it can be seen that processing is handled with margin.

Enlarged View

In the enlarged waveform diagram, the period when CS (SD Card) is Low represents reading from the SD card, and the period when DCS (VS1053) is Low represents the bitstream writing period to VS1053. Also, the period when CS (VS1053) is Low indicates SCI access, which occurs when operations such as volume changes are performed.

Summary

With TinyGo, multithreaded processing can be written cleanly using Go language multithreading features such as goroutine and chan, and it appears to be highly practical. Additionally, it should be noted that in this project, the chan size is 1 with no buffering, meaning testing was done in a state where even a momentary failure in event passing could easily cause a lock, but within the range tested so far, it has operated stably without freezing even once.

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