discorb-voice 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|