🐡

discord ボイスチャットで遊んでみた

に公開

背景

ここ最近 discord をよく用いるようになりました(仲間内だと LINE より discord のほうが何かと便利).そんな便利な discord ですが,どんな仕組みで動いているのか,どこまで遊べそうかが気になったので, discord Bot として実装しながら遊んでみました.この記事は,その際のメモです.

実際やったこと

以前の実装を基にして,主にボイスチャットの仕組みを golang で実装しながら雰囲気を確かめる程度のことです.本当であればstftで音声の可視化くらいまでしたかったのですが, golang の channel 型と音声データのコーデック周りで手こずり,時間がありませんでした...

webRTC

Discord では webRTC (Real Time Communication) を用いてリアルタイムな音声・映像などの相互通信を実現しています.これは,その名の通り web (HTML5) の仕様の一つとして実装されているリアルタイム通信技術です.

https://www.iwass.co.jp/column/column-07.html
https://www.paltek.co.jp/techblog/techinfo/230201_01

特に音声 (Audio) については,一般的に Opus という形式に圧縮されて送受信されます.

Discord ボイスチャットの仕組み

discord のボイスチャットの情報は,VoiceConnection という構造体で管理されています.
中身は以下.

type VoiceConnection struct {
sync.RWMutex

Debug 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 string

OpusSend chan []byte // Chan for sending opus audio
OpusRecv chan *Packet // Chan for receiving opus audio
// contains filtered or unexported fields
}

https://pkg.go.dev/github.com/bwmarrin/discordgo#section-readme

これを実際に生成する関数は次のようになります.

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
}

また,この構造体の中にある OpusSendOpusRecv というのが,webRTC によって送受信されるOpusの変数です.
ちなみに,chan とあるのは,ポインタ変数を表しています.

実際に bot が音声を送受信する場合には,何かしらの変換(ノイズ除去など)を行うことになります.しかし,Opus は圧縮形式であるため,そのままでは操作ができません.
そのため,Opus をデコードしPCM形式とすることで任意の操作を可能にし,それをまたOpusにエンコードしなおします.
このコーデックに関する操作には以下を用いました.
https://github.com/hraban/opus

実験と結果

実験として,ユーザからボイスチャットに入力された音声を検出し,デコード及びエンコードしたのちにそのまま送り返すecho機能を実装しました.
使用したコードは付録として記事の最後に張り付けています.
結果としては,コーデックの変換をしない場合にはクリアに聞こえたが,コーデックした場合には高速に再生されており,どこかが誤っているのだが原因はいまだ不明です.(誰か助けてクレメンス)

まとめ

Discord の音声通信についてメモしました.今後は,音声のフーリエ変換と可視化を進め,音声符号化あたりについて勉強したいと思っています.
可視化については,discord Bot が映像配信可能になってくれればなぁ...

参考

https://zenn.dev/fog/scraps/327d8b40148d1f

https://www.aatomu.work/blog/2025010801_discord_opus

https://zenn.dev/yukihaga/scraps/48e39f1e5ec3bf

https://r9y9.github.io/blog/2014/06/08/gossp-speech-signal-processing-for-go/

https://qiita.com/KEINOS/items/731ce80dd7a5485caccc

https://www.infraexpert.com/study/telephony2.html

付録:今回の実装

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
   }
}
GitHubで編集を提案

Discussion