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 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: []