dP            dP                d8888b. .d888b. d8             dP       dP                   
  88            88                    `88 Y8' `8P 88             88       88                   
d8888P dP    dP 88d888b. .d8888b. .aaadP' d8bad8b .P .d8888b.    88d888b. 88 .d8888b. .d8888b. 
  88   88    88 88'  `88 88'  `88 88'     88` `88    Y8ooooo.    88'  `88 88 88'  `88 88'  `88 
  88   88.  .88 88.  .88 88.  .88 88.     8b. .88          88    88.  .88 88 88.  .88 88.  .88 
  dP   `88888P' 88Y8888' `88888P' Y88888P Y88888P    `88888P'    88Y8888' dP `88888P' `8888P88 
                                                                                           .88 
                                                                                       d8888P
つぼのブログ

PCM の音声データを Discord Bot で再生する

| tech go discord opus

Discord Bot API が対応している音声コーデックは Opus です。なので、自分の Discord bot で再生するには、手元の音声データを Opus にエンコードする必要があります。

Go だと libopus をラップしたライブラリがあります。

https://github.com/hraban/opus

音声ファイルから PCM (波の高さを一定時間ごとに記録した一連のデータ) を取り出せさえすれば、あとはこれを使ってエンコードすればよいわけです。 といっても、libopus はある程度の Opus のことが分かっていないと使えないないライブラリです。僕も完全には分かっていないのですが、とりあえず動くものができたので記事にします。

// opus はフレームという単位に音声を切って非可逆圧縮を行います。
// その幅をここでは 20 ms とします。
// https://datatracker.ietf.org/doc/html/rfc6716#section-2.1.4
const fwMS = 20

// pcm を Opus に変換する
func pcmToOpus(pcm []int, sampleRate, numChannels int) ([][]byte, error) {
	// fw が整数にならなければエラーとする (これでいいのかは知らない)
	if sampleRate%(1000/fwMS) != 0 {
		return nil, fmt.Errorf("sampling rate %% %d must be zero", 1000/fwMS)
	}
	// fwMS の間にあるサンプルの数 
	fw := sampleRate * fwMS / 1000
	enc, err := opus.NewEncoder(sampleRate, numChannels, opus.AppVoIP)
	if err != nil {
		log.Printf("error loading opus file: %s", err)
	}

	// 全体が fw の倍数になるように調整する
	// 最大で fwMS-1 ミリ秒無音の音声が末尾に追加されるが妥協する
	for len(pcm)%fw != 0 {
		pcm = append(pcm, 0)
	}

	// frame と呼ばれる単位でエンコードする
	frames := make([][]byte, 0, len(pcm)/fw)
	i16tmp := make([]int16, fw)
	for i := 0; i < len(pcm); i += fw {
		for j := 0; j < fw; j += 1 {
			i16tmp[j] = int16(pcm[i+j])
		}
		fr := make([]byte, 1024)
		n, err := enc.Encode(i16tmp, fr)
		if err != nil {
			return nil, fmt.Errorf("error encoding to opus: %w", err)
		}
		fr = fr[:n]
		frames = append(frames, fr)
	}
	return frames, nil
}

discordgo で作った bot なら次のようにして喋らせます。

var s *discordgo.Session = ...

// 音声データから pcm を得る
var pcm []int = ...

// 音声データのサンプリングレート
sr := 44100

// 音声データのチャンネル数
// 1 以外ではテストしていません!
nc := 1 

opus, err := pcmToOpus(pcm, sr, nc)
if err != nil {
    log.Printf("error encoding to opus: %s", err)
    return
}

conn, ok := s.VoiceConnections[guildID]
if !ok {
    log.Printf("error voice connection not found on guild %s", guildID)
    return
}

conn.Speaking(true)
for _, f := range opus {
    conn.OpusSend <- f
}
conn.Speaking(false)