Portaudio を Go 言語で触ってみた

仕事ではめっきり Ruby, TypeScript ばかりなので Go をちゃんと触らないとなと考えていた。そこで、2022 年度の年末年始はオーディオ関連の操作をしてみようと思い立った。 作ってみたいと思っていたソフトウェアの処理のうち、まずはマイクから音声を拾うところから触れてみようということで、今回は PortAudio を Go でラップしたパッケージを使って数秒間隔で音声の記録を試してみた。

パッケージの導入

go get github.com/gordonklaus/portaudio

コード

雑に書いた PCMRecorder 構造体とその関数たち

recorder/recorder.go

  • Start() では OpenDefaultStream() によって初期設定になっているマイクから音声を拾うストリームを開き、後のループで Stream が受け取ったデータを読み込む。
  • 読み込まれたデータは PCMRecorder.Input に一時的に格納される。
  • Stream の開始時間と今の時刻から経過時間を算出して、 PCMRecorder.Interval で渡された秒数が経過したかチェックしている。
  • 上記間隔で新規ファイルにバッファの内容を書き出す
package recorder

import (
	"fmt"
	"log"
	"os"
	"time"

	"github.com/gordonklaus/portaudio"
)

type PCMRecorder struct {
	file     *os.File
	FilePath string
	Interval int
	Input    []int16
	Data     []int16
	stream   *portaudio.Stream
}

func NewPCMRecorder(filePath string, interval int) *PCMRecorder {
	var pr = &PCMRecorder{
		FilePath: filePath,
		Interval: interval,
	}
	return pr
}

func (pr PCMRecorder) Start(sig chan os.Signal) error {
	portaudio.Initialize()
	defer portaudio.Terminate()

	pr.Input = make([]int16, 64)
	stream, err := portaudio.OpenDefaultStream(1, 0, 44100, len(pr.Input), pr.Input)
	if err != nil {
		log.Fatalf("Could not open default stream \n %v", err)
	}
	pr.stream = stream
	pr.stream.Start()
	defer pr.stream.Close()

	startTime := pr.stream.Time()

loop:
	for {
		elapseTime := (pr.stream.Time() - startTime).Round(time.Second)

		if err := pr.stream.Read(); err != nil {
			fmt.Println(err)
			log.Fatalf("Could not read stream\n%v", err)
		}

		// Turn the volume up
		pr.Data = append(pr.Data, changeVolume(pr.Input, 10)...)

		select {
		case <-sig:
			break loop
		default:
		}

		// Create a new file to record audio per PCMRecorder.Interval seconds.
		if int(elapseTime.Seconds())%pr.Interval == 0 {
			outputFileName := fmt.Sprintf(pr.FilePath+"_%d", int(elapseTime.Seconds()))
			if !exists(outputFileName) {
				pr.file, err = os.Create(outputFileName)
				if err != nil {
					log.Fatalf("Could not create a new file to write \n %v", err)
				}
				defer func() {
					if err := pr.file.Close(); err != nil {
						log.Fatalf("Could not close output file \n %v", err)
					}
				}()

				fmt.Println("A new LPCM file was created", outputFileName, elapseTime)
				if err := binary.Write(pr.file, binary.BigEndian, pr.Data); err != nil {
					log.Fatalf("Could not write data\n %v", err)
				}
				fmt.Printf("File is written successfully. length: %d\n", len(pr.Data))

				pr.Data = nil
				fmt.Println("tmp buffer initialized.")
			}
		}
	}

	return nil
}

func changeVolume(input []int16, vol float32) (output []int16) {
	output = make([]int16, len(input))

	for i := 0; i < len(output); i++ {
		output[i] = int16(float32(input[i]) * vol)
	}

	return output
}

func exists(fileName string) bool {
	_, err := os.Stat(fileName)
	return err == nil
}

main.go

package main

import (
	"fmt"
	"os"
	"os/signal"
	"time"

	"github.com/killinsun/go-portaudio-study/recorder"
)

func main() {
	fmt.Println("Streaming. Press Ctrl + C to stop.")

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, os.Kill)

	outDir := time.Now().Format("audio_20060102_T150405")
	if err := os.MkdirAll(outDir, 0755); err != nil {
		panic("Could not create a new directory")
	}

	pr := recorder.NewPCMRecorder(fmt.Sprintf(outDir+"/file"), 5)
	pr.Start(sig)
}

補足

経過時間について

elapseTime の部分は、元々 サンプリングレートから割り出そうと思ったんだけど PortAudio 側で時間に関するインターフェースがあったのでそっちを使った。

ファイルの区切り方について(無音制御など)

たとえばこれを Google の Speech-to-text のような API に投げる場合を想定したとき、3〜5秒間隔で区切ってストリームに流すことが考えられるが、発話の途中で区切られてしまうと文字起こしの精度が悪くなると想像している。

その場合、pr.Input で一時的に読み取ったバッファーの値がすべて特定の閾値*内であり、それが連続して受け取るようなら「話していない」と判断して pr.Dataへ格納するのをスキップするのが考えられる。

やってみて

オーディオインターフェース周りの処理は複雑そうで難しいというイメージだったが、PortAudio がうまく差異を吸収してくれるおかげで、プログラムを書く際は読み取った PCM のデジタルデータをどのように書き出すかを意識さえすれば良くなって非常に楽だった。

ただしサンプリングレートやチャンネルなど映像・音声で出てくる用語に馴染みがない、もしくはあっても私のように知識が曖昧な状態だと、ストリームの値を読み取ってもどのように処理すべきか最初は手こずる。

一旦ブログ用に LPCM 形式のデータを書き出すところだけかいつまんだが、実際は .wav 形式にして保存している。それは go-wav パッケージを使っているのだが、調べてもめぼしい記事がなかったので、別途記事に残す予定。

参考


/以上