discorb-voice 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/Rakefile +4 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/discorb-voice.gemspec +39 -0
- data/examples/music_bot.rb +38 -0
- data/examples/simple_player.rb +25 -0
- data/lib/discorb/voice/core.rb +314 -0
- data/lib/discorb/voice/error.rb +7 -0
- data/lib/discorb/voice/extend.rb +64 -0
- data/lib/discorb/voice/ogg.rb +120 -0
- data/lib/discorb/voice/source.rb +110 -0
- data/lib/discorb/voice/version.rb +8 -0
- data/lib/discorb/voice.rb +39 -0
- data/lib/discorb-voice.rb +1 -0
- metadata +107 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: fea20d97b1be8d5c3e395d50d4a1cfa1b2de4b926be3610acfda6d16b8ecdf27
|
4
|
+
data.tar.gz: 7810db23cdd333483653881e9c629844e6963359e106677520b5c2569a0f45b4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4097e618d134677434518ab6d94d5a7f35c524c1248c16666fc2246c59f31a8685c7d4ed080a3c28cd6de222ea04346daa44805a2afa94f94994cb26fc7bb9c7
|
7
|
+
data.tar.gz: a97ab6487013e35614252008948acff3a7177bf637066ec35b480852d9e3a84332d7196d7f89dc6099f176d7fd444ee9984f9be3a41a25c4ebaaff323113b785
|
data/.gitignore
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 sevenc-nanashi
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
# discorb-voice
|
2
|
+
|
3
|
+
This adds a voice support to discorb.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'discorb-voice'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle install
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install discorb-voice
|
20
|
+
|
21
|
+
### Install libsodium
|
22
|
+
|
23
|
+
#### Windows
|
24
|
+
|
25
|
+
Get libsodium from [here](https://download.libsodium.org/libsodium/releases/).
|
26
|
+
I've checked `libsodium-1.0.17-stable-mingw.tar.gz` works.
|
27
|
+
|
28
|
+
Then, if you are using x64 ruby (Check with `ruby -e 'puts RUBY_PLATFORM'`)...
|
29
|
+
1. extract `libsodium-win64`
|
30
|
+
2. copy `libsodium-23.dll` in `bin` to `C:/Windows/System32/sodium.dll`
|
31
|
+
|
32
|
+
If you are using x86 ruby...
|
33
|
+
1. extract `libsodium-win32`
|
34
|
+
2. copy `libsodium-23.dll` in `bin` to `C:/Windows/SysWOW64/sodium.dll`.
|
35
|
+
|
36
|
+
#### Linux
|
37
|
+
|
38
|
+
$ sudo apt-get install libsodium-dev
|
39
|
+
|
40
|
+
Get libsodium with your package manager.
|
41
|
+
|
42
|
+
#### Mac
|
43
|
+
|
44
|
+
$ brew install libsodium
|
45
|
+
|
46
|
+
### Install ffmpeg
|
47
|
+
|
48
|
+
#### Windows
|
49
|
+
|
50
|
+
Get ffmpeg from [here](https://ffmpeg.org/download.html).
|
51
|
+
And put the `ffmpeg.exe` on your PATH.
|
52
|
+
|
53
|
+
Or, you can use Chocolatey to install ffmpeg:
|
54
|
+
|
55
|
+
$ choco install ffmpeg
|
56
|
+
|
57
|
+
#### Linux
|
58
|
+
|
59
|
+
$ sudo apt-get install ffmpeg
|
60
|
+
|
61
|
+
Use your package manager to install ffmpeg.
|
62
|
+
|
63
|
+
#### Mac
|
64
|
+
|
65
|
+
$ brew install ffmpeg
|
66
|
+
|
67
|
+
## Usage
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
require "discorb"
|
71
|
+
require "discorb-voice"
|
72
|
+
|
73
|
+
|
74
|
+
client.once :standby do
|
75
|
+
puts "Logged in as #{client.user}"
|
76
|
+
end
|
77
|
+
|
78
|
+
client.slash "connect", "connect to a voice channel" do |interaction|
|
79
|
+
channel = interaction.target.voice_state.channel
|
80
|
+
interaction.post "Connecting to #{channel.name}"
|
81
|
+
channel.connect.wait
|
82
|
+
interaction.post "Connected to #{channel.name}"
|
83
|
+
end
|
84
|
+
|
85
|
+
client.slash "play", "Plays audio" do |interaction|
|
86
|
+
interaction.guild.voice_client.play(Discorb::Voice::FFmpegAudio.new("./very-nice-song.mp3"))
|
87
|
+
interaction.post "Playing Your very nice song!"
|
88
|
+
end
|
89
|
+
|
90
|
+
client.slash "stop", "Stops the current audio" do |interaction|
|
91
|
+
interaction.guild.voice_client.stop
|
92
|
+
interaction.post "Stopped"
|
93
|
+
end
|
94
|
+
|
95
|
+
client.run(ENV["DISCORD_BOT_TOKEN"])
|
96
|
+
```
|
97
|
+
|
98
|
+
## Development
|
99
|
+
|
100
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
101
|
+
|
102
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
103
|
+
|
104
|
+
## Contributing
|
105
|
+
|
106
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/discorb-lib/discorb-voice.
|
107
|
+
|
108
|
+
## License
|
109
|
+
|
110
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
111
|
+
|
112
|
+
## Acknowledgments
|
113
|
+
|
114
|
+
The implementation of the voice client is based on [discord.py](https://github.com/Rapptz/discord.py) library.
|
115
|
+
|
116
|
+
```
|
117
|
+
Copyright (c) 2015-present Rapptz
|
118
|
+
Released under the MIT license
|
119
|
+
https://github.com/Rapptz/discord.py/blob/master/LICENSE
|
120
|
+
```
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "discorb/voice"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/discorb/voice/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "discorb-voice"
|
7
|
+
spec.version = Discorb::Voice::VERSION
|
8
|
+
spec.authors = ["sevenc-nanashi"]
|
9
|
+
spec.email = ["sevenc-nanashi@sevenbot.jp"]
|
10
|
+
|
11
|
+
spec.summary = "Gem for connecting voice channels with discorb."
|
12
|
+
spec.homepage = "https://github.com/discorb-lib/discorb-voice"
|
13
|
+
spec.license = "MIT"
|
14
|
+
spec.required_ruby_version = ">= 3.0.0"
|
15
|
+
|
16
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
17
|
+
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/discorb-lib/discorb-voice"
|
20
|
+
# spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
|
21
|
+
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
26
|
+
end
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
# Uncomment to register a new dependency of your gem
|
32
|
+
# spec.add_dependency "example-gem", "~> 1.0"
|
33
|
+
spec.add_dependency "discorb", ">= 0.13.0"
|
34
|
+
spec.add_dependency "ffi", ">= 1.15.3"
|
35
|
+
spec.add_dependency "rbnacl"
|
36
|
+
|
37
|
+
# For more information and examples about making a new gem, checkout our
|
38
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
39
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require "discorb"
|
2
|
+
require "discorb/voice"
|
3
|
+
require "open3"
|
4
|
+
|
5
|
+
client = Discorb::Client.new
|
6
|
+
|
7
|
+
client.once :standby do
|
8
|
+
puts "Logged in as #{client.user}"
|
9
|
+
end
|
10
|
+
|
11
|
+
client.slash "connect", "connect to a voice channel" do |interaction|
|
12
|
+
channel = interaction.target.voice_state.channel
|
13
|
+
interaction.post "Connecting to #{channel.name}"
|
14
|
+
channel.connect.wait
|
15
|
+
interaction.post "Connected to #{channel.name}"
|
16
|
+
end
|
17
|
+
|
18
|
+
client.slash "play", "Plays YouTube audio", {
|
19
|
+
"url" => {
|
20
|
+
type: :string,
|
21
|
+
description: "The URL of the YouTube video to play",
|
22
|
+
required: true,
|
23
|
+
},
|
24
|
+
} do |interaction, url|
|
25
|
+
interaction.post "Querying #{url}..."
|
26
|
+
stdout, _status = Open3.capture2("youtube-dl", "-j", url)
|
27
|
+
data = JSON.parse(stdout, symbolize_names: true)
|
28
|
+
url = data[:formats][0][:url]
|
29
|
+
interaction.guild.voice_client.play(Discorb::Voice::FFmpegAudio.new(url))
|
30
|
+
interaction.post "Playing `#{data[:title]}`"
|
31
|
+
end
|
32
|
+
|
33
|
+
client.slash "stop", "Stops the current audio" do |interaction|
|
34
|
+
interaction.guild.voice_client.stop
|
35
|
+
interaction.post "Stopped"
|
36
|
+
end
|
37
|
+
|
38
|
+
client.run(ENV["DISCORD_BOT_TOKEN"])
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "discorb"
|
2
|
+
require "discorb-voice"
|
3
|
+
|
4
|
+
client.once :standby do
|
5
|
+
puts "Logged in as #{client.user}"
|
6
|
+
end
|
7
|
+
|
8
|
+
client.slash "connect", "connect to a voice channel" do |interaction|
|
9
|
+
channel = interaction.target.voice_state.channel
|
10
|
+
interaction.post "Connecting to #{channel.name}"
|
11
|
+
channel.connect.wait
|
12
|
+
interaction.post "Connected to #{channel.name}"
|
13
|
+
end
|
14
|
+
|
15
|
+
client.slash "play", "Plays audio" do |interaction|
|
16
|
+
interaction.guild.voice_client.play(Discorb::Voice::FFmpegAudio.new("./very-nice-song.mp3"))
|
17
|
+
interaction.post "Playing Your very nice song!"
|
18
|
+
end
|
19
|
+
|
20
|
+
client.slash "stop", "Stops the current audio" do |interaction|
|
21
|
+
interaction.guild.voice_client.stop
|
22
|
+
interaction.post "Stopped"
|
23
|
+
end
|
24
|
+
|
25
|
+
client.run(ENV["DISCORD_BOT_TOKEN"])
|
@@ -0,0 +1,314 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "async"
|
4
|
+
require "async/websocket"
|
5
|
+
require "rbnacl"
|
6
|
+
require "socket"
|
7
|
+
|
8
|
+
module Discorb
|
9
|
+
module Connectable
|
10
|
+
def connect
|
11
|
+
Async do
|
12
|
+
@client.connect_to(self).wait
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Voice
|
18
|
+
OPUS_SAMPLE_RATE = 48000
|
19
|
+
OPUS_FRAME_LENGTH = 20
|
20
|
+
|
21
|
+
class Client
|
22
|
+
# @private
|
23
|
+
attr_reader :connect_condition
|
24
|
+
# @return [:connecting, :connected, :closed, :ready, :reconnecting] The current status of the voice connection
|
25
|
+
attr_reader :status
|
26
|
+
# @return [:stopped, :playing] The current status of playing audio
|
27
|
+
attr_reader :playing_status
|
28
|
+
# @return [Async::Condition] The condition of playing audio
|
29
|
+
attr_reader :playing_condition
|
30
|
+
|
31
|
+
# @private
|
32
|
+
def initialize(client, data)
|
33
|
+
@client = client
|
34
|
+
@token = data[:token]
|
35
|
+
@guild_id = data[:guild_id]
|
36
|
+
@endpoint = data[:endpoint]
|
37
|
+
@status = :connecting
|
38
|
+
@playing_status = :stopped
|
39
|
+
@connect_condition = Async::Condition.new
|
40
|
+
@paused_condition = Async::Condition.new
|
41
|
+
@play_condition = Async::Condition.new
|
42
|
+
Async do
|
43
|
+
start_receive false
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Sends a speaking indicator to the server.
|
49
|
+
#
|
50
|
+
# @param [Boolean] high_priority Whether to send audio in high priority.
|
51
|
+
#
|
52
|
+
def speaking(high_priority: false)
|
53
|
+
flag = 1
|
54
|
+
flag |= 1 << 2 if high_priority
|
55
|
+
send_connection_message(5, {
|
56
|
+
speaking: flag,
|
57
|
+
delay: 0,
|
58
|
+
ssrc: @ssrc,
|
59
|
+
})
|
60
|
+
end
|
61
|
+
|
62
|
+
def stop_speaking
|
63
|
+
send_connection_message(5, {
|
64
|
+
speaking: false,
|
65
|
+
delay: 0,
|
66
|
+
ssrc: @ssrc,
|
67
|
+
})
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Plays audio from a source.
|
72
|
+
#
|
73
|
+
# @param [Discorb::Voice::Source] source data The audio source
|
74
|
+
# @param [Boolean] high_priority Whether to play audio in high priority
|
75
|
+
#
|
76
|
+
def play(source, high_priority: false)
|
77
|
+
@playing_task = Async do
|
78
|
+
speaking(high_priority: high_priority)
|
79
|
+
@playing_status = :playing
|
80
|
+
@playing_condition = Async::Condition.new
|
81
|
+
stream = OggStream.new(source.io)
|
82
|
+
loops = 0
|
83
|
+
@start_time = Time.now.to_f
|
84
|
+
delay = OPUS_FRAME_LENGTH / 1000.0
|
85
|
+
|
86
|
+
stream.packets.each_with_index do |packet, i|
|
87
|
+
@connect_condition.wait if @status == :connecting
|
88
|
+
if @playing_status == :stopped
|
89
|
+
source.cleanup
|
90
|
+
break
|
91
|
+
elsif @playing_status == :paused
|
92
|
+
@paused_condition.wait
|
93
|
+
end
|
94
|
+
# p i
|
95
|
+
@timestamp += (OPUS_SAMPLE_RATE / 1000.0 * OPUS_FRAME_LENGTH).to_i
|
96
|
+
@sequence += 1
|
97
|
+
# puts packet.data[...10].unpack1("H*")
|
98
|
+
# puts packet[-10..]&.unpack1("H*")
|
99
|
+
send_audio(packet)
|
100
|
+
# puts "Sent packet #{i}"
|
101
|
+
loops += 1
|
102
|
+
next_time = @start_time + (delay * (loops + 1))
|
103
|
+
# p [next_time, Time.now.to_f, delay]
|
104
|
+
sleep(next_time - Time.now.to_f) if next_time > Time.now.to_f
|
105
|
+
# @voice_connection.flush
|
106
|
+
end
|
107
|
+
# p :e
|
108
|
+
# @playing_status = :stopped
|
109
|
+
# @playing_condition.signal
|
110
|
+
# source.cleanup
|
111
|
+
# stop_speaking
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Note: This is commented out because it raises an error.
|
116
|
+
# It's not clear why this is happening.
|
117
|
+
# #
|
118
|
+
# # Pause playing audio.
|
119
|
+
# #
|
120
|
+
# def pause
|
121
|
+
# raise VoiceError, "Not playing" unless @playing_status == :playing
|
122
|
+
# send_audio(OPUS_SILENCE)
|
123
|
+
# @paused_condition = Async::Condition.new
|
124
|
+
# @paused_offset = Time.now.to_f - @start_time
|
125
|
+
# @playing_status = :paused
|
126
|
+
# end
|
127
|
+
|
128
|
+
# #
|
129
|
+
# # Resumes playing audio.
|
130
|
+
# #
|
131
|
+
# def resume
|
132
|
+
# raise VoiceError, "Not paused" unless @playing_status == :paused
|
133
|
+
# @paused_condition.signal
|
134
|
+
# @start_time = Time.now.to_f - @paused_offset
|
135
|
+
# end
|
136
|
+
|
137
|
+
#
|
138
|
+
# Stop playing audio.
|
139
|
+
#
|
140
|
+
def stop
|
141
|
+
@playing_status = :stopped
|
142
|
+
send_audio(OPUS_SILENCE)
|
143
|
+
end
|
144
|
+
|
145
|
+
#
|
146
|
+
# Disconnects from the voice server.
|
147
|
+
#
|
148
|
+
def disconnect
|
149
|
+
@connection.close
|
150
|
+
@client.disconnect_voice(@guild_id)
|
151
|
+
cleanup
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
OPUS_SILENCE = [0xF8, 0xFF, 0xFE].pack("C*")
|
157
|
+
|
158
|
+
def cleanup
|
159
|
+
@heartbeat_task&.stop
|
160
|
+
|
161
|
+
@voice_connection&.close
|
162
|
+
end
|
163
|
+
|
164
|
+
def send_audio(data)
|
165
|
+
header = create_header
|
166
|
+
@voice_connection.send(
|
167
|
+
header + encrypt_audio(
|
168
|
+
data,
|
169
|
+
header
|
170
|
+
),
|
171
|
+
0
|
172
|
+
# @sockaddr
|
173
|
+
)
|
174
|
+
rescue IOError
|
175
|
+
@client.log.warn("Voice connection closed")
|
176
|
+
@playing_task.stop if @status != :closed
|
177
|
+
end
|
178
|
+
|
179
|
+
def start_receive(resume)
|
180
|
+
Async do
|
181
|
+
endpoint = Async::HTTP::Endpoint.parse("wss://" + @endpoint + "?v=4", alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
|
182
|
+
@client.log.info("Connecting to #{endpoint}")
|
183
|
+
Async::WebSocket::Client.connect(endpoint, handler: Discorb::Gateway::RawConnection) do |conn|
|
184
|
+
@connection = conn
|
185
|
+
@status = :connected
|
186
|
+
if resume
|
187
|
+
send_connection_message(
|
188
|
+
7,
|
189
|
+
{
|
190
|
+
server_id: @guild_id,
|
191
|
+
session_id: @client.session_id,
|
192
|
+
token: @token,
|
193
|
+
}
|
194
|
+
)
|
195
|
+
else
|
196
|
+
send_connection_message(
|
197
|
+
0,
|
198
|
+
{
|
199
|
+
server_id: @guild_id,
|
200
|
+
user_id: @client.user.id,
|
201
|
+
session_id: @client.session_id,
|
202
|
+
token: @token,
|
203
|
+
}
|
204
|
+
)
|
205
|
+
end
|
206
|
+
while (raw_message = @connection.read)
|
207
|
+
message = JSON.parse(raw_message, symbolize_names: true)
|
208
|
+
handle_voice_connection(message)
|
209
|
+
end
|
210
|
+
rescue Async::Wrapper::Cancelled
|
211
|
+
@status = :closed
|
212
|
+
cleanup
|
213
|
+
rescue Errno::EPIPE
|
214
|
+
@status = :reconnecting
|
215
|
+
@connect_condition = Async::Condition.new
|
216
|
+
start_receive false
|
217
|
+
rescue Protocol::WebSocket::ClosedError => e
|
218
|
+
case e.code
|
219
|
+
when 4014
|
220
|
+
@status = :closed
|
221
|
+
cleanup
|
222
|
+
when 4006
|
223
|
+
@status = :reconnecting
|
224
|
+
@connect_condition = Async::Condition.new
|
225
|
+
start_receive false
|
226
|
+
when 4015, 1001, 4009
|
227
|
+
@status = :reconnecting
|
228
|
+
@connect_condition = Async::Condition.new
|
229
|
+
start_receive true
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def handle_voice_connection(message)
|
236
|
+
@client.log.debug("Voice connection message: #{message}")
|
237
|
+
data = message[:d]
|
238
|
+
# pp data
|
239
|
+
case message[:op]
|
240
|
+
when 8
|
241
|
+
@heartbeat_task = handle_heartbeat(data[:heartbeat_interval])
|
242
|
+
when 2
|
243
|
+
@port, @ip = data[:port], data[:ip]
|
244
|
+
@client.log.debug("Connected to voice UDP, #{@ip}:#{@port}")
|
245
|
+
@sockaddr = Socket.pack_sockaddr_in(@port, @ip)
|
246
|
+
@voice_connection = UDPSocket.new
|
247
|
+
@voice_connection.connect(@ip, @port)
|
248
|
+
@ssrc = data[:ssrc]
|
249
|
+
|
250
|
+
@local_ip, @local_port = discover_ip.wait
|
251
|
+
# p @local_ip, @local_port
|
252
|
+
send_connection_message(1, {
|
253
|
+
protocol: "udp",
|
254
|
+
data: {
|
255
|
+
address: @local_ip,
|
256
|
+
port: @local_port,
|
257
|
+
mode: "xsalsa20_poly1305",
|
258
|
+
},
|
259
|
+
})
|
260
|
+
@sequence = 0
|
261
|
+
@timestamp = 0
|
262
|
+
when 4
|
263
|
+
@secret_key = data[:secret_key].pack("C*")
|
264
|
+
@box = RbNaCl::SecretBox.new(@secret_key)
|
265
|
+
@connect_condition.signal
|
266
|
+
@status = :ready
|
267
|
+
when 9
|
268
|
+
@connect_condition.signal
|
269
|
+
@status = :ready
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def create_header
|
274
|
+
[0x80, 0x78, @sequence, @timestamp, @ssrc].pack("CCnNN").ljust(12, "\0")
|
275
|
+
end
|
276
|
+
|
277
|
+
def encrypt_audio(buf, nonce)
|
278
|
+
@box.box(nonce.ljust(24, "\0"), buf)
|
279
|
+
end
|
280
|
+
|
281
|
+
def discover_ip
|
282
|
+
Async do
|
283
|
+
packet = [
|
284
|
+
1, 70, @ssrc,
|
285
|
+
].pack("S>S>I>").ljust(70, "\0")
|
286
|
+
@voice_connection.send(packet, 0, @sockaddr)
|
287
|
+
recv = @voice_connection.recv(70)
|
288
|
+
ip_start = 4
|
289
|
+
ip_end = recv.index("\0", ip_start)
|
290
|
+
[recv[ip_start...ip_end], recv[-2, 2].unpack1("S>")]
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def handle_heartbeat(interval)
|
295
|
+
Async do
|
296
|
+
loop do
|
297
|
+
sleep(interval / 1000.0 * 0.9)
|
298
|
+
send_connection_message(3, Time.now.to_i)
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
def send_connection_message(op, data)
|
304
|
+
@connection.write(
|
305
|
+
{
|
306
|
+
op: op,
|
307
|
+
d: data,
|
308
|
+
}.to_json
|
309
|
+
)
|
310
|
+
@connection.flush
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Discorb::Voice
|
4
|
+
# @private
|
5
|
+
module ClientVoicePrepend
|
6
|
+
attr_reader :voice_conditions
|
7
|
+
|
8
|
+
def initialize(*, **)
|
9
|
+
super
|
10
|
+
@voice_clients = Discorb::Dictionary.new
|
11
|
+
@voice_conditions = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def event_voice_server_update(data)
|
15
|
+
@log.debug("Received VOICE_SERVER_UPDATE")
|
16
|
+
client = Discorb::Voice::Client.new(self, data)
|
17
|
+
@voice_clients[data[:guild_id]] = client
|
18
|
+
client.connect_condition.wait
|
19
|
+
@voice_conditions[data[:guild_id]].signal client
|
20
|
+
end
|
21
|
+
|
22
|
+
def disconnect_voice(guild_id)
|
23
|
+
send_gateway(
|
24
|
+
4,
|
25
|
+
guild_id: guild_id,
|
26
|
+
channel_id: nil,
|
27
|
+
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def connect_to(channel)
|
32
|
+
Async do
|
33
|
+
@log.debug("Connecting to #{channel.id}")
|
34
|
+
send_gateway(
|
35
|
+
4,
|
36
|
+
guild_id: channel.guild.id.to_s,
|
37
|
+
channel_id: channel.id.to_s,
|
38
|
+
self_mute: channel.guild.me.voice_state&.self_mute?,
|
39
|
+
self_deaf: channel.guild.me.voice_state&.self_deaf?,
|
40
|
+
|
41
|
+
)
|
42
|
+
condition = Async::Condition.new
|
43
|
+
@voice_conditions[channel.guild.id.to_s] = condition
|
44
|
+
condition.wait
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class Discorb::Client
|
51
|
+
# @return [Discorb::Dictionary{String => Discorb::Voice::Client}] The voice clients.
|
52
|
+
attr_accessor :voice_clients
|
53
|
+
|
54
|
+
prepend Discorb::Voice::ClientVoicePrepend
|
55
|
+
end
|
56
|
+
|
57
|
+
class Discorb::Guild
|
58
|
+
# @!attribute [r] voice_client
|
59
|
+
# @return [Discorb::Voice::Client] The voice client.
|
60
|
+
# @return [nil] If the client is not connected to the voice server.
|
61
|
+
def voice_client
|
62
|
+
@client.voice_clients[@id.to_s]
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Discorb::Voice
|
2
|
+
#
|
3
|
+
# Represents a Ogg stream.
|
4
|
+
#
|
5
|
+
class OggStream
|
6
|
+
#
|
7
|
+
# Creates a new Ogg stream.
|
8
|
+
#
|
9
|
+
# @param [IO] io The audio ogg stream.
|
10
|
+
#
|
11
|
+
def initialize(io)
|
12
|
+
@io = io
|
13
|
+
end
|
14
|
+
|
15
|
+
#
|
16
|
+
# Enumerates the packets of the Ogg stream.
|
17
|
+
#
|
18
|
+
# @return [Enumerator<Discorb::Voice::OggStream::Page::Packet>] The packets of the Ogg stream.
|
19
|
+
#
|
20
|
+
def packets
|
21
|
+
Enumerator.new do |enum|
|
22
|
+
part = +""
|
23
|
+
raw_packets.each do |packet|
|
24
|
+
part << packet.data
|
25
|
+
unless packet.partial
|
26
|
+
enum << part
|
27
|
+
part = +""
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
#
|
34
|
+
# Enumerates the raw packets of the Ogg stream.
|
35
|
+
# This may include partial packets.
|
36
|
+
#
|
37
|
+
# @return [Enumerator<Discorb::Voice::OggStream::Page::Packet>] The raw packets of the Ogg stream.
|
38
|
+
#
|
39
|
+
def raw_packets
|
40
|
+
Enumerator.new do |enum|
|
41
|
+
loop do
|
42
|
+
# p c += 1
|
43
|
+
if @io.read(4) != "OggS"
|
44
|
+
break
|
45
|
+
end
|
46
|
+
pg = Page.new(@io)
|
47
|
+
pg.packets.each do |packet|
|
48
|
+
enum << packet
|
49
|
+
end
|
50
|
+
# enum << pg.packets.next
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# Represents a page of the Ogg stream.
|
57
|
+
#
|
58
|
+
class Page
|
59
|
+
# @return [Struct] The struct of the packet
|
60
|
+
Packet = Struct.new(:data, :partial, :page)
|
61
|
+
# @return [Integer] The version of the page.
|
62
|
+
attr_reader :version
|
63
|
+
# @return [Integer] The header type of the page.
|
64
|
+
attr_reader :header_type
|
65
|
+
# @return [Integer] The granule position of the page.
|
66
|
+
attr_reader :granule_position
|
67
|
+
# @return [Integer] The bitstream serial number of the page.
|
68
|
+
attr_reader :bitstream_serial_number
|
69
|
+
# @return [Integer] The page sequence number of the page.
|
70
|
+
attr_reader :page_sequence_number
|
71
|
+
# @return [Integer] The CRC checksum of the page.
|
72
|
+
attr_reader :crc_checksum
|
73
|
+
# @return [Integer] The length of the page segment table.
|
74
|
+
attr_reader :page_segments
|
75
|
+
# @return [String] The body of the page.
|
76
|
+
attr_reader :body
|
77
|
+
|
78
|
+
#
|
79
|
+
# Creates a new page.
|
80
|
+
#
|
81
|
+
# @param [IO] io The audio ogg stream.
|
82
|
+
# @note This method will seek the io.
|
83
|
+
#
|
84
|
+
def initialize(io)
|
85
|
+
@version, @header_type, @granule_position, @bitstream_serial_number, @page_sequence_number, @crc_checksum, @page_segments =
|
86
|
+
io.read(23).unpack("CCQ<L<L<L<C")
|
87
|
+
@segtable = io.read(@page_segments)
|
88
|
+
len = @segtable.unpack("C*").sum
|
89
|
+
@body = io.read(len)
|
90
|
+
end
|
91
|
+
|
92
|
+
#
|
93
|
+
# Enumerates the packets of the page.
|
94
|
+
#
|
95
|
+
# @return [Enumerator<Discorb::Voice::OggStream::Page::Packet>] The packets of the page.
|
96
|
+
#
|
97
|
+
def packets
|
98
|
+
Enumerator.new do |enum|
|
99
|
+
offset = 0
|
100
|
+
length = 0
|
101
|
+
partial = true
|
102
|
+
@segtable.bytes.each do |seg|
|
103
|
+
if seg == 255
|
104
|
+
length += 255
|
105
|
+
partial = true
|
106
|
+
else
|
107
|
+
length += seg
|
108
|
+
partial = false
|
109
|
+
enum << Packet.new(@body[offset, length], partial, self)
|
110
|
+
offset += length
|
111
|
+
length = 0
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
enum << Packet.new(@body[offset, length], partial, self) if partial
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require "open3"
|
2
|
+
require "tmpdir"
|
3
|
+
|
4
|
+
module Discorb::Voice
|
5
|
+
#
|
6
|
+
# The source of audio data.
|
7
|
+
# @abstract
|
8
|
+
#
|
9
|
+
class Source
|
10
|
+
#
|
11
|
+
# The audio ogg stream. This MUST be implemented by subclasses.
|
12
|
+
#
|
13
|
+
# @return [#read] The audio ogg stream.
|
14
|
+
#
|
15
|
+
def io
|
16
|
+
raise NotImplementedError
|
17
|
+
end
|
18
|
+
|
19
|
+
#
|
20
|
+
# Clean up the source.
|
21
|
+
# This does nothing by default.
|
22
|
+
#
|
23
|
+
def cleanup
|
24
|
+
# noop
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
#
|
29
|
+
# Plays audio from a source, using FFmpeg.
|
30
|
+
# @note You must install FFmpeg and should be on the PATH.
|
31
|
+
#
|
32
|
+
class FFmpegAudio
|
33
|
+
attr_reader :stdin, :stdout, :stderr, :process
|
34
|
+
|
35
|
+
#
|
36
|
+
# Creates a new FFmpegAudio.
|
37
|
+
#
|
38
|
+
# @param [String, IO] source The source of audio data.
|
39
|
+
# @param [Integer] bitrate The bitrate of the audio.
|
40
|
+
# @param [{String => String}] extra_options Extra options for FFmpeg. This will be passed before `-i`.
|
41
|
+
# @param [{String => String}] extra_options2 Extra options for FFmpeg. This will be passed after `-i`.
|
42
|
+
#
|
43
|
+
def initialize(source, bitrate: 128, extra_options: {}, extra_options2: {})
|
44
|
+
if source.is_a?(String)
|
45
|
+
source_path = source
|
46
|
+
@tmp_path = nil
|
47
|
+
else
|
48
|
+
source_path = "#{Dir.tmpdir}/#{Process.pid}.#{source.object_id}"
|
49
|
+
@tmp_path = source_path
|
50
|
+
File.open(source_path, "wb") do |f|
|
51
|
+
while chunk = source.read(4096)
|
52
|
+
f.write(chunk)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
args = %W[ffmpeg]
|
57
|
+
extra_options.each do |key, value|
|
58
|
+
args += ["-#{key}", "#{value}"]
|
59
|
+
end
|
60
|
+
args += %W[
|
61
|
+
-i #{source_path}
|
62
|
+
-f opus
|
63
|
+
-c:a libopus
|
64
|
+
-ar 48000
|
65
|
+
-ac 2
|
66
|
+
-b:a #{bitrate}k
|
67
|
+
-loglevel warning
|
68
|
+
-map_metadata -1]
|
69
|
+
extra_options2.each do |key, value|
|
70
|
+
args += ["-#{key}", "#{value}"]
|
71
|
+
end
|
72
|
+
args += %w[pipe:1]
|
73
|
+
@stdin, @stdout, @process = Open3.popen2(*args)
|
74
|
+
end
|
75
|
+
|
76
|
+
def io
|
77
|
+
@stdout
|
78
|
+
end
|
79
|
+
|
80
|
+
#
|
81
|
+
# Kills the FFmpeg process.
|
82
|
+
#
|
83
|
+
def cleanup
|
84
|
+
@process.kill
|
85
|
+
if @tmp_path
|
86
|
+
File.delete(@tmp_path)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
#
|
92
|
+
# Plays audio from a ogg file.
|
93
|
+
#
|
94
|
+
class OggAudio
|
95
|
+
attr_reader :io
|
96
|
+
|
97
|
+
#
|
98
|
+
# Opens an ogg file.
|
99
|
+
#
|
100
|
+
# @param [String, IO] src The ogg file to open, or an IO object.
|
101
|
+
#
|
102
|
+
def initialize(src)
|
103
|
+
if src.is_a?(String)
|
104
|
+
@io = File.open(src, "rb")
|
105
|
+
else
|
106
|
+
@io = src
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "discorb"
|
4
|
+
begin
|
5
|
+
require "rbnacl"
|
6
|
+
rescue LoadError
|
7
|
+
raise LoadError, <<~EOS, cause: nil
|
8
|
+
Could not load libsodium library.
|
9
|
+
Follow the instructions at https://github.com/discorb-lib/discorb-voice#install-libsodium
|
10
|
+
EOS
|
11
|
+
end
|
12
|
+
require "open3"
|
13
|
+
begin
|
14
|
+
ffmpeg_version = Open3.capture2e("ffmpeg -version")[0]
|
15
|
+
rescue Errno::ENOENT
|
16
|
+
raise LoadError, <<~EOS, cause: nil
|
17
|
+
Could not find ffmpeg.
|
18
|
+
Follow the instructions at https://github.com/discorb-lib/discorb-voice#install-ffmpeg
|
19
|
+
EOS
|
20
|
+
else
|
21
|
+
line = ffmpeg_version.split("\n").find { |line| line.start_with?("configuration: ") }
|
22
|
+
unless line.include? "--enable-libopus"
|
23
|
+
raise LoadError, <<~EOS, cause: nil
|
24
|
+
Your ffmpeg version does not support opus.
|
25
|
+
Install ffmpeg with opus support.
|
26
|
+
EOS
|
27
|
+
end
|
28
|
+
end
|
29
|
+
require_relative "voice/version"
|
30
|
+
require_relative "voice/extend"
|
31
|
+
require_relative "voice/core"
|
32
|
+
require_relative "voice/ogg"
|
33
|
+
require_relative "voice/source"
|
34
|
+
|
35
|
+
module Discorb
|
36
|
+
module Voice
|
37
|
+
class Error < StandardError; end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require "discorb/voice"
|
metadata
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: discorb-voice
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- sevenc-nanashi
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-12-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: discorb
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.13.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.13.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: ffi
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.15.3
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 1.15.3
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rbnacl
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description:
|
56
|
+
email:
|
57
|
+
- sevenc-nanashi@sevenbot.jp
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- ".gitignore"
|
63
|
+
- CHANGELOG.md
|
64
|
+
- Gemfile
|
65
|
+
- LICENSE.txt
|
66
|
+
- README.md
|
67
|
+
- Rakefile
|
68
|
+
- bin/console
|
69
|
+
- bin/setup
|
70
|
+
- discorb-voice.gemspec
|
71
|
+
- examples/music_bot.rb
|
72
|
+
- examples/simple_player.rb
|
73
|
+
- lib/discorb-voice.rb
|
74
|
+
- lib/discorb/voice.rb
|
75
|
+
- lib/discorb/voice/core.rb
|
76
|
+
- lib/discorb/voice/error.rb
|
77
|
+
- lib/discorb/voice/extend.rb
|
78
|
+
- lib/discorb/voice/ogg.rb
|
79
|
+
- lib/discorb/voice/source.rb
|
80
|
+
- lib/discorb/voice/version.rb
|
81
|
+
homepage: https://github.com/discorb-lib/discorb-voice
|
82
|
+
licenses:
|
83
|
+
- MIT
|
84
|
+
metadata:
|
85
|
+
allowed_push_host: https://rubygems.org
|
86
|
+
homepage_uri: https://github.com/discorb-lib/discorb-voice
|
87
|
+
source_code_uri: https://github.com/discorb-lib/discorb-voice
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options: []
|
90
|
+
require_paths:
|
91
|
+
- lib
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 3.0.0
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubygems_version: 3.2.32
|
104
|
+
signing_key:
|
105
|
+
specification_version: 4
|
106
|
+
summary: Gem for connecting voice channels with discorb.
|
107
|
+
test_files: []
|