discorb-voice 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2021-09-11
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in discorb-voice.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "discorb", path: "../discorb"
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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,7 @@
1
+ module Discorb::Voice
2
+ #
3
+ # Error for voice connection.
4
+ #
5
+ class VoiceError < StandardError
6
+ end
7
+ 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discorb
4
+ module Voice
5
+ # @return [String] The version of the library.
6
+ VERSION = "0.1.0"
7
+ end
8
+ 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: []