TinyGoでMP3再生 (VS1053)

2022年6月4日土曜日

Raspberry Pi Pico TinyGo

t f B! P L

Raspberry Pi PicoとVS1053を使用して、TinyGoでMP3再生のサンプルプロジェクトを作成してみました。

Raspberry Pi Pico + VS1053 テスト風景

概要

TinyGoでSDカードを使う (速度改善編)で準備したTinyGoによるSDカードアクセス環境をベースに、MP3再生用LSIであるVS1053制御を加えていく形で進めます。VS1053の制御はAdafruit_VS1053_Libraryの基本的な部分をそのままTinyGoに移植します。ただし、このライブラリでビットストリームの読み出し、書き込みが割り込みルーチン内で行われている部分は、他の機能を追加していくうえで取り回し上、制約を与えることになりますので、この部分をGo言語のマルチスレッド機能を使用した処理に変更しました。

VS1053制御

MP3デコーダLSIであるVS1053の制御方法は次の通りです。

  • VS1053に対するコマンドはSPIインターフェイス経由のSCIで行う
  • VS1053のDREQ端子の立ち上がりを割り込みで検出して、MP3のビットストリームをSPIインターフェイス経由のSDIにて書き込みを行う

同じLSIにSCIとSDI用のそれぞれのCSがありSPIインターフェイスを共有するイメージになります。今回のプロジェクトではここにSDカード用のSPIアクセスも加わりますので、3系統のSPIアクセスを1つのSPIインターフェイスで共有する構成となります。また一旦MP3再生が始まると、非同期的にデータの要求のDREQ割り込みを受け付けてバッファに書き込みを行う必要があります。

使用するGo機能

そこで、以下のTinyGoのマルチスレッド関連機能を活用します。

  • chanを使用して割り込みルーチンからサブスレッドにイベント送信
  • goroutineを使用してサブスレッドにてMP3ビットストリーム読み出し&書き込み
  • Mutexを使用してSPIの排他処理 (SPIをVS1053とSDカードで共有)

割り込みルーチンでは、割り込みが来たことを通知するイベントを送信するのみにして、goroutineで立ち上げたサブスレッドにてイベントを受け付けて、SDカードからのデータ読み出し及びMP3 LSIへのデータ書き込みを行うようにします。このようにすることでメインスレッドでは各種設定ならびにMP3 LSIに対するコマンド処理、サブスレッドではSDカード読み出しとMP3 LSIへの書き込みを行うことになり、またひとつだけのSPIインターフェイスリソースを排他制御しながら共有することが出来ます。このような処理自体はC++でコーディングする場合と特段変わることはありませんが、このような処理をTinyGoでどのように記述できるか、実際のプロジェクトで試してみるのが今回のテーマです。

配線図

配線図を以下に示します。SPI信号はSDカードとVS1053でSPI1を共用していますが、プロジェクトの設定を変更することにより別のSPIに割り当てることも可能です。

配線図

コード説明

では、pico_tinygo_vs1053プロジェクトのコードを説明していきます。

chan

まず、MP3プレーヤーのメインで使用する構造体にmp3BufReqというchanを準備します。今回のプロジェクトではイベント通知のみを行えばよいので、chanのデータ自体は空 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{}
}

chan初期化はMP3ファイルの再生を開始するタイミングで行うようにします。chanの初期化にはmake()を使用します。chanのサイズを1より大きくすればバッファリングも可能ですがこのプロジェクトでは(テストの意味もあり)REQ_CH_SZに1を使用しています。サイズを1にした場合は、送信したイベントが確実に受け取られないと、次のイベントを送信することが出来ません。
次に、DREQ割り込み処理を設定します。ここではmp3BufReqにイベント送信するのみの無名関数を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)
    })
    ...

setDreqInterrupt()内では、DREQ端子の立ち上がりエッジでfunc()をコールバックするように設定しています。


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

では、goroutineによるサブスレッドの処理を見ていきます。下記コードの1行目にかなりの要素が集約されています。

  • go 関数名によるサブスレッド定義
  • サブスレッドには無名関数を割り当て
  • サブスレッドには空のデータ(struct{})の受け取りchanがreqとしてattachされる

func()の中では、_, more := <-req の部分でchanからデータを取り出しますが、重要なポイントはchanがopen状態にある場合で、何も送信されていない間はここでブロッキングされる点です。したがって、この行より下に処理が移行した場合は、chanに受信すべきデータがあるか、chanがcloseされたかのどちらかになり、その判別はmoreによって行うことが出来ます。moreの左の '_' は本来chanで受け取ったデータが入る変数ですが、今回は空のデータ (struct{}) が来ることがわかっているので、使い捨てにするためにこのようにしてします。
moreがtrueの場合はDREQ割り込みによるイベントが送信された状態ですので、feedBuffer()をコールして、MP3ビットストリームをSDカードから読み出し、VS1053のSDIへ書き込みます。
moreがfalseの場合chanがcloseされていますので、サブスレッド自体を終了するためにreturnします。
一番下の(p.mp3BufReq)の部分は見慣れていないと一瞬混乱しますが、無名関数funcの引数reqに割り当てる実際の変数です。


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)

上記でchanのcloseがサブスレッドの終了に関連付けられていましたが、chanのcloseが実際に行われる部分を下記に示します。要は、MP3ファイルから読み込むべきビットストリームがなくなったらchanをcloseするだけの簡単な処理です。実際には再生途中でユーザーが再生を終了させる場合にも、chanをcloseする処理を行います。chanをcloseすれば、goroutine側でサブスレッドが終了するあたりは、非常にシンプルかつ合理的な流れを簡単に記述できると思いました。


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

最後にSPIを排他使用するためのMutexを見ていきます。SDカードへのアクセス、MP3 LSIのSCIアクセス、SDIアクセスの3系統が1つのSPIを共有しますので、Mutexをリソース側のSPIに置きます。具体的にはmymachine.SPIの構造体にmu変数をsync.Mutexのポインタとして準備して、SPI構造体の初期化の際にmuを初期化 (&sync.Mutex{}) するようにしています。


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()
}

実際に使用する側の例としてMP3 LSIのSDIアクセスの例を以下に示します。d.bus.Lock()の部分でリソースが取得できるまでブロッキングされます。次の行に移行したときにはSPIが取得できているので、開放 d.bus.Unlock()をdeferで予約しておいて、実際に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)
}

その他

マルチスレッドに直接関連の無い部分についても説明します。


SPIのinterface化

SPIを使用する側ではSPIをInterface定義することでmymachineパッケージとの依存関係をなくしました。


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,
    }
}

SetBaudRate()の高速化

SPIのSetBaudRate()メソッドはRaspberry Pi Pico向けに、SCKの周波数ターゲット値のみからレジスタ設定してくれる便利なメソッドですが、今回のようにSDカードアクセス、SCIアクセス、SDIアクセスするたびに適切なSCK周波数に切り替えるために呼び出すと相当なオーバーヘッドとなり、MP3再生が間に合いませんでした。したがって、一度計算したレジスタ設定を設定周波数をキーとするmapに保存するようにして2回目以降のコールからは、すでに計算した結果のレジスタ値を直接反映するようにして高速化しました。


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アクセス波形

以下にSPIアクセスのロジアナ波形を示します。

全体図

全体的な波形図では、DREQのHigh期間に着目する必要があります。DREQのHigh期間にビットストリームの読み出し、書き込みが行われますが、DREQ Low期間が充分に確保されているので、余裕をもって処理されていることがわかります。

拡大図

拡大波形図において、CS (SD Card)が Lowの期間がSDカードからの読み出し、DCS (VS1053)が Lowの期間がVS1053に対するビットストリームの書き込み期間になります。また、CS (VS1053)がLowの期間は、SCIアクセスを示し、ボリュームなどの操作が行われた場合に発生します。

まとめ

TinyGoでは、Go言語のマルチスレッド機能であるgoroutineやchanの機能を使用して、マルチスレッド処理をすっきりとした形で記述でき、かつ実用性も高そうです。また、今回のプロジェクトではchanのサイズは1でバッファリングを行わない状態なので、イベントを受け渡しが一瞬でも失敗すると容易にロックしてしまう状態でテストをしていますが、これまでテストした範囲では一度もフリーズすることなく安定して動作していることも記載しておきます。

自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ