discord ボイスチャットで遊んでみた
背景
ここ最近 discord をよく用いるようになりました(仲間内だと LINE より discord のほうが何かと便利).そんな便利な discord ですが,どんな仕組みで動いているのか,どこまで遊べそうかが気になったので, discord Bot として実装しながら遊んでみました.この記事は,その際のメモです.
実際やったこと
以前の実装を基にして,主にボイスチャットの仕組みを golang で実装しながら雰囲気を確かめる程度のことです.本当であればstftで音声の可視化くらいまでしたかったのですが, golang の channel 型と音声データのコーデック周りで手こずり,時間がありませんでした...
webRTC
Discord では webRTC (Real Time Communication) を用いてリアルタイムな音声・映像などの相互通信を実現しています.これは,その名の通り web (HTML5) の仕様の一つとして実装されているリアルタイム通信技術です.
特に音声 (Audio) については,一般的に Opus という形式に圧縮されて送受信されます.
Discord ボイスチャットの仕組み
discord のボイスチャットの情報は,VoiceConnection という構造体で管理されています.
中身は以下.
type VoiceConnection struct {
sync.RWMutexDebug bool // If true, print extra logging -- DEPRECATED
LogLevel int
Ready bool // If true, voice is ready to send/receive audio
UserID string
GuildID string
ChannelID stringOpusSend chan []byte // Chan for sending opus audio
OpusRecv chan *Packet // Chan for receiving opus audio
// contains filtered or unexported fields
}
これを実際に生成する関数は次のようになります.
func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
c, err := s.State.Channel(m.ChannelID)
if err != nil {
log.Println("Error channel state: ", err)
}
vcsession, _ = s.ChannelVoiceJoin(c.GuildID, "channel id", false, false)
// vcsession : type VoiceConnection struct
}
また,この構造体の中にある OpusSend, OpusRecv というのが,webRTC によって送受信されるOpusの変数です.
ちなみに,chan とあるのは,ポインタ変数を表しています.
実際に bot が音声を送受信する場合には,何かしらの変換(ノイズ除去など)を行うことになります.しかし,Opus は圧縮形式であるため,そのままでは操作ができません.
そのため,Opus をデコードしPCM形式とすることで任意の操作を可能にし,それをまたOpusにエンコードしなおします.
このコーデックに関する操作には以下を用いました.
実験と結果
実験として,ユーザからボイスチャットに入力された音声を検出し,デコード及びエンコードしたのちにそのまま送り返すecho機能を実装しました.
使用したコードは付録として記事の最後に張り付けています.
結果としては,コーデックの変換をしない場合にはクリアに聞こえたが,コーデックした場合には高速に再生されており,どこかが誤っているのだが原因はいまだ不明です.(誰か助けてクレメンス)
まとめ
Discord の音声通信についてメモしました.今後は,音声のフーリエ変換と可視化を進め,音声符号化あたりについて勉強したいと思っています.
可視化については,discord Bot が映像配信可能になってくれればなぁ...
参考
付録:今回の実装
package main
import (
"flag"
"fmt"
"log"
"os"
"strings"
"os/signal"
"syscall"
"github.com/bwmarrin/discordgo"
"gopkg.in/hraban/opus.v2"
)
var (
split_q []string
vcsession *discordgo.VoiceConnection
HelloWorld = "!hello"
chanList = "!clist"
vcJoin = "!vcjoin"
vcLeave = "!vcleave"
)
func main() {
Token := os.Getenv("TOKEN")
discord, err := discordgo.New("Bot " + Token)
if err != nil {
fmt.Println("ログインに失敗", err)
}
// Add Event Handler
discord.AddHandler(onMessageCreate)
// vcsession.AddHandler(onVoiceReceived) //音声受信時のイベントハンドラ <- TODO
err = discord.Open()
if err != nil {
fmt.Println("セッションのオープンに失敗", err)
}
defer discord.Close()
fmt.Println("Listening...")
waitForExitSignal()
}
func waitForExitSignal() {
stopBot := make(chan os.Signal, 1)
signal.Notify(stopBot, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-stopBot
}
func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
clientID := os.Getenv("CLIENT_ID") // Bot's ID
u := m.Author
if u.ID != clientID {
fmt.Printf("%20s %20s(%20s) > %s\n", m.ChannelID, u.Username, u.ID, m.Content)
c, err := s.State.Channel(m.ChannelID)
if err != nil {
log.Println("Error channel state: ", err)
}
switch {
case strings.HasPrefix(m.Content, fmt.Sprintf("<@%s> %s", clientID, HelloWorld)):
sendMessage(s, m.ChannelID, "Hello "+u.Username, m.Reference())
case strings.HasPrefix(m.Content, fmt.Sprintf("<@%s> %s", clientID, chanList)):
guildChannels, _ := s.GuildChannels(c.GuildID)
var sendText string
for _, a := range guildChannels{
sendText += fmt.Sprintf("Type: %v, Name: %v(ID: %v)\n", a.Type, a.Name, a.ID)
}
sendMessage(s, m.ChannelID, sendText, m.Reference())
case strings.HasPrefix(m.Content, fmt.Sprintf("<@%s> %s", clientID, vcJoin)):
split_q = strings.Split(m.Content, " ")
log.Printf("%s\n", split_q)
vcsession, _ = s.ChannelVoiceJoin(c.GuildID, split_q[2], false, false)
voiceControl(vcsession)
case strings.HasPrefix(m.Content, fmt.Sprintf("<@%s> %s", clientID, vcLeave)):
vcsession.Disconnect()
}
}
}
func sendMessage(s *discordgo.Session, channelID string, msg string, reference *discordgo.MessageReference) {
_, err := s.ChannelMessageSendReply(channelID, msg, reference)
if err != nil {
log.Println("Error sending message: ", err)
}
}
func voiceControl(v *discordgo.VoiceConnection) {
recv := make(chan *discordgo.Packet)
go rPCM(v, recv)
send := make(chan []int16, 2)
go sPCM(v, send)
i := 0
for p := range recv{
log.Print(n, i)
i++
send <- p.PCM
}
}
func rPCM(v *discordgo.VoiceConnection, c chan *discordgo.Packet) {
pcm := make([]int16, int(48000*2*20/1000)) // sample_rate = 48000, channels = 2, 20ms
mode := "recieve"
for{
p := <- v.OpusRecv
dec, _ := opus.NewDecoder(48000, 2)
n, err := dec.Decode(p.Opus, pcm)
if err != nil {
log.Print(err)
return
}
p.PCM = pcm[:n*2]
c <- p
pcm_float := make([]float64, n)
for i := 0; i < n; i++ {
pcm_float[i] = float64(pcm[i])
}
if mode=="echo" {
v.OpusSend <- p.Opus
}
}
}
func sPCM(v *discordgo.VoiceConnection, pcm chan []int16) {
for {
p := <- v.OpusRecv
p.PCM = <- pcm
enc, _ := opus.NewEncoder(48000, 2, opus.AppVoIP)
// enc, _ := opus.NewEncoder(48000, 2, opus.AppAudio)
n, err2 := enc.Encode(p.PCM, p.Opus)
if err2 != nil {
log.Print("err2")
log.Print(err2)
return
}
p.Opus = p.Opus[:n]
v.OpusSend <- p.Opus
}
}
Discussion