自作音楽プレーヤーの音飛びがなぜ起きていたか

公開:2020/09/18
更新:2020/09/22
4 min読了の目安(約2900字TECH技術記事

はじめに

先日、自宅専用に音楽プレーヤーを作りました。音楽プレーヤーといってもちょっと特殊で、別途 Raspberry Pi で起動しているストリーミングサーバから配信されている Vorbis の音声を受信してスピーカーを鳴らすという物です。

ストリーミングサーバとしては Music Player Daemon を使っています。

そもそも何故作るのか

Music Player Daemon は音楽ファイルが格納されているディレクトリから音楽ファイルを再生する為のデーモンです。音楽ファイルの置いてあるサーバから音が出せますが、Raspberry Pi にはスピーカーを付けていません。そして出来れば音声は別の端末で再生したいのです。その為の用途として Music Player Daemon は HTTPD として配信する事もできる機能を持っています。
個人的な用途としては音楽ファイルは Raspberry Pi で1か所に保持し、他の端末はそれを聴きにいく様な仕組みが作りたかったのです。

ですが Music Player Daemon のオフィシャルが提供しているコントローラ mpc は HTTPD で配信している音声を再生する事はできません。ブラウザ等で HTTPD の URL を開けばいいのですが、操作する場所と再生される場所が異なるのはちょっと納得が行きませんでした。

そこで音声も再生できて、かつ曲の次送り程度ができる機能を両方持っているコマンドラインツールが作りたかったのです。

作り方

Go 言語で作りました。

https://github.com/mattn/mpcc

コマンドの -addr フラグで Music Player Daemon のアドレス (IP:PORT) を、-stream フラグで HTTPD の URL を指定します。環境変数でデフォルト値が渡せます。

Music Player Daemon との通信は github.com/fhs/gompd/mpd というパッケージがある為、それを使わせて頂きました。スピーカーを鳴らす方法については github.com/hajimehoshi/oto を、Vorbis のデコードについては oto の作者さん Hajime Hoshi さんに github.com/jfreymuth/oggvorbis をオススメされたのでそれを使いました。

問題が起きた

作っている過程で、なぜかプツプツという音飛びが発生している事に気付きました。ログを仕込んで調べたりしたのですが良く分からず、vim-jp の Slack の皆さんにも参加してデバッグしてみたけど分からず、以下の様な推測をしてしまいました。

ストリーミングサーバからバイト列を受信し、oggvorbis でデコード、oto で再生という流れのどこかでバッファリングが出来てない

色々とバッファリングを入れたり処理を非同期にしたりと試したのですがまったく改善せず、これはライブラリのどこかにバグがあるんだろうと勝手に思い込んでしまいました。

残念、バグはお前のコードだ

色々調べる中で以下のコードに問題がある事に気付きました。

samples := make([]float32, bufferSize)
for {
	st.Read(samples)

	for i, val := range samples {
		if val < -1 {
			val = -1
		}
		if val > +1 {
			val = +1
		}
		valInt16 := int16(val * (1<<15 - 1))
		low := byte(valInt16)
		high := byte(valInt16 >> 8)
		buf[i*2+0] = low
		buf[i*2+1] = high
	}
	player.Write(buf)
}

これは oggvorbis がバッファからサンプリングし、low/high に分けてスピーカーに流す処理です。このコードそのものは oto を使った音声再生ライブラリ github.com/faiface/beep から参考にしました。スピーカーに書き込むバイト列 buf はサンプリングするバッファ samples ([]float32)を low/high に分解するので samples のサイズの2倍にしています。

numBytes := bufferSize * 2
buf := make([]byte, numBytes)

実は上記のコードの st.Read(samples) には戻り値があります。これは読み込んだサンプリング数ですが、Read は毎回その返す数が異なります。この値を使って samples をスライスしてやらないと、固定サイズで作られている buf の後ろにはゴミが残ってしまう事になります。言い訳ですが、このコードは beep から拝借しました。(言い訳がましい)

この buf の後ろに作られた壊れた音声データが毎回スピーカーに流れるのですから、そりゃあプツプツという音飛びが起きるわけです。

for i, val := range samples[:n] {
	if val < -1 {
		val = -1
	}
	if val > +1 {
		val = +1
	}
	valInt16 := int16(val * (1<<15 - 1))
	low := byte(valInt16)
	high := byte(valInt16 >> 8)
	buf[i*2+0] = low
	buf[i*2+1] = high
}
player.Write(buf[:n*2])

ちゃんと読み込む数と書き込む数でスライスしてあげる事でバグが解消しました。Go 言語ではソケット通信プログラムを作る際に以下の様なコードをよく見ると思いますが、結果で言えばこれが出来ていないのと同じでした。

var buf [4096]byte
n, err := conn.Read(buf[:])
if err != nil {
    return err
}
fmt.Println(string(buf[:n]))

おわりに

読み込んだデータは、ちゃんと読み込んだサイズで処理しろ(自戒)