discorb-voice 0.1.0 → 0.1.1

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.
@@ -1,314 +1,326 @@
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
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 Voice
10
+ OPUS_SAMPLE_RATE = 48_000
11
+ OPUS_FRAME_LENGTH = 20
12
+
13
+ #
14
+ # Client for voice connection.
15
+ #
16
+ class Client
17
+ # @private
18
+ attr_reader :connect_condition
19
+ # @return [:connecting, :connected, :closed, :ready, :reconnecting] The current status of the voice connection
20
+ attr_reader :status
21
+ # @return [:stopped, :playing] The current status of playing audio
22
+ attr_reader :playing_status
23
+ # @return [Async::Condition] The condition of playing audio
24
+ attr_reader :playing_condition
25
+
26
+ # @private
27
+ def initialize(client, data)
28
+ @client = client
29
+ @token = data[:token]
30
+ @guild_id = data[:guild_id]
31
+ @endpoint = data[:endpoint]
32
+ @status = :connecting
33
+ @playing_status = :stopped
34
+ @connect_condition = Async::Condition.new
35
+ @paused_condition = Async::Condition.new
36
+ @play_condition = Async::Condition.new
37
+ Async do
38
+ start_receive false
39
+ end
40
+ end
41
+
42
+ #
43
+ # Sends a speaking indicator to the server.
44
+ #
45
+ # @param [Boolean] high_priority Whether to send audio in high priority.
46
+ #
47
+ def speaking(high_priority: false)
48
+ flag = 1
49
+ flag |= 1 << 2 if high_priority
50
+ send_connection_message(5, {
51
+ speaking: flag,
52
+ delay: 0,
53
+ ssrc: @ssrc,
54
+ })
55
+ end
56
+
57
+ def stop_speaking
58
+ send_connection_message(5, {
59
+ speaking: false,
60
+ delay: 0,
61
+ ssrc: @ssrc,
62
+ })
63
+ end
64
+
65
+ #
66
+ # Plays audio from a source.
67
+ #
68
+ # @param [Discorb::Voice::Source] source data The audio source
69
+ # @param [Boolean] high_priority Whether to play audio in high priority
70
+ #
71
+ def play(source, high_priority: false)
72
+ @playing_task = Async do
73
+ speaking(high_priority: high_priority)
74
+ @playing_status = :playing
75
+ @playing_condition = Async::Condition.new
76
+ stream = OggStream.new(source.io)
77
+ loops = 0
78
+ @start_time = Time.now.to_f
79
+ delay = OPUS_FRAME_LENGTH / 1000.0
80
+
81
+ stream.packets.each_with_index do |packet, _i|
82
+ if @playing_status == :stopped
83
+ source.cleanup
84
+ break
85
+ elsif @playing_status == :paused
86
+ @paused_condition.wait
87
+ elsif @status != :ready
88
+ sleep 0.02 while @status != :ready
89
+
90
+ speaking(high_priority: high_priority)
91
+ end
92
+ # p i
93
+ @timestamp += (OPUS_SAMPLE_RATE / 1000.0 * OPUS_FRAME_LENGTH).to_i
94
+ @sequence += 1
95
+ # puts packet.data[...10].unpack1("H*")
96
+ # puts packet[-10..]&.unpack1("H*")
97
+ send_audio(packet)
98
+ # puts "Sent packet #{i}"
99
+ loops += 1
100
+ next_time = @start_time + (delay * (loops + 1))
101
+ # p [next_time, Time.now.to_f, delay]
102
+ sleep(next_time - Time.now.to_f) if next_time > Time.now.to_f
103
+ # @voice_connection.flush
104
+ end
105
+ # p :e
106
+ @playing_status = :stopped
107
+ @playing_condition.signal
108
+ source.cleanup
109
+ stop_speaking
110
+ end
111
+ end
112
+
113
+ # NOTE: This is commented out because it raises an error.
114
+ # It's not clear why this is happening.
115
+ # #
116
+ # # Pause playing audio.
117
+ # #
118
+ # def pause
119
+ # raise VoiceError, "Not playing" unless @playing_status == :playing
120
+ # send_audio(OPUS_SILENCE)
121
+ # @paused_condition = Async::Condition.new
122
+ # @paused_offset = Time.now.to_f - @start_time
123
+ # @playing_status = :paused
124
+ # end
125
+
126
+ # #
127
+ # # Resumes playing audio.
128
+ # #
129
+ # def resume
130
+ # raise VoiceError, "Not paused" unless @playing_status == :paused
131
+ # @paused_condition.signal
132
+ # @start_time = Time.now.to_f - @paused_offset
133
+ # end
134
+
135
+ #
136
+ # Stop playing audio.
137
+ #
138
+ def stop
139
+ @playing_status = :stopped
140
+ send_audio(OPUS_SILENCE)
141
+ end
142
+
143
+ #
144
+ # Disconnects from the voice server.
145
+ #
146
+ def disconnect
147
+ begin
148
+ @connection.close
149
+ rescue StandardError
150
+ nil
151
+ end
152
+ @client.disconnect_voice(@guild_id)
153
+ cleanup
154
+ end
155
+
156
+ private
157
+
158
+ OPUS_SILENCE = [0xF8, 0xFF, 0xFE].pack("C*")
159
+
160
+ def cleanup
161
+ @heartbeat_task&.stop
162
+
163
+ @voice_connection&.close
164
+ end
165
+
166
+ def send_audio(data)
167
+ header = create_header
168
+ @voice_connection.send(
169
+ header + encrypt_audio(
170
+ data,
171
+ header
172
+ ),
173
+ 0
174
+ # @sockaddr
175
+ )
176
+ rescue IOError
177
+ @client.logger.warn("Voice UDP connection closed")
178
+ @playing_task.stop if @status != :closed
179
+ end
180
+
181
+ def start_receive(resume)
182
+ Async do
183
+ @client.voice_mutexes[@guild_id] ||= Mutex.new
184
+ next if @client.voice_mutexes[@guild_id].locked?
185
+ @client.voice_mutexes[@guild_id].synchronize do
186
+ endpoint = Async::HTTP::Endpoint.parse("wss://" + @endpoint + "?v=4", alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
187
+ @client.logger.info("Connecting to #{endpoint}")
188
+ @connection = Async::WebSocket::Client.connect(endpoint, handler: Discorb::Gateway::RawConnection)
189
+ @status = :connected
190
+ if resume
191
+ send_connection_message(
192
+ 7,
193
+ {
194
+ server_id: @guild_id,
195
+ session_id: @client.session_id,
196
+ token: @token,
197
+ }
198
+ )
199
+ else
200
+ send_connection_message(
201
+ 0,
202
+ {
203
+ server_id: @guild_id,
204
+ user_id: @client.user.id,
205
+ session_id: @client.session_id,
206
+ token: @token,
207
+ }
208
+ )
209
+ end
210
+ while (raw_message = @connection.read)
211
+ message = JSON.parse(raw_message, symbolize_names: true)
212
+ handle_voice_connection(message)
213
+ end
214
+ rescue Async::Wrapper::Cancelled
215
+ @status = :closed
216
+ cleanup
217
+ rescue Errno::EPIPE, EOFError
218
+ @status = :reconnecting
219
+ @connect_condition = Async::Condition.new
220
+ @client.voice_mutexes[@guild_id].unlock
221
+ start_receive true
222
+ rescue Protocol::WebSocket::ClosedError => e
223
+ case e.code
224
+ when 4014
225
+ @status = :closed
226
+ cleanup
227
+ when 4006
228
+ @status = :reconnecting
229
+ @connect_condition = Async::Condition.new
230
+ @client.voice_mutexes[@guild_id].unlock
231
+ start_receive false
232
+ else
233
+ @status = :closed
234
+ cleanup
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ def handle_voice_connection(message)
241
+ @client.logger.debug("Voice connection message: #{message}")
242
+ data = message[:d]
243
+ # pp data
244
+ case message[:op]
245
+ when 8
246
+ @heartbeat_task = handle_heartbeat(data[:heartbeat_interval])
247
+ when 2
248
+ @port, @ip = data[:port], data[:ip]
249
+ @client.logger.debug("Connecting to voice UDP, #{@ip}:#{@port}")
250
+ @sockaddr = Socket.pack_sockaddr_in(@port, @ip)
251
+ @voice_connection = UDPSocket.new
252
+ @voice_connection.connect(@ip, @port)
253
+ @ssrc = data[:ssrc]
254
+
255
+ @local_ip, @local_port = discover_ip.wait
256
+ # p @local_ip, @local_port
257
+ send_connection_message(1, {
258
+ protocol: "udp",
259
+ data: {
260
+ address: @local_ip,
261
+ port: @local_port,
262
+ mode: "xsalsa20_poly1305",
263
+ },
264
+ })
265
+ @sequence = 0
266
+ @timestamp = 0
267
+ when 4
268
+ @secret_key = data[:secret_key].pack("C*")
269
+ @box = RbNaCl::SecretBox.new(@secret_key)
270
+ @connect_condition.signal
271
+ @status = :ready
272
+ when 9
273
+ @connect_condition.signal
274
+ @status = :ready
275
+ end
276
+ end
277
+
278
+ def create_header
279
+ [0x80, 0x78, @sequence, @timestamp, @ssrc].pack("CCnNN").ljust(12, "\0")
280
+ end
281
+
282
+ def encrypt_audio(buf, nonce)
283
+ @box.box(nonce.ljust(24, "\0"), buf)
284
+ end
285
+
286
+ def discover_ip
287
+ Async do
288
+ packet = [
289
+ 1, 70, @ssrc,
290
+ ].pack("S>S>I>").ljust(70, "\0")
291
+ @voice_connection.send(packet, 0, @sockaddr)
292
+ recv = @voice_connection.recv(70)
293
+ ip_start = 4
294
+ ip_end = recv.index("\0", ip_start)
295
+ [recv[ip_start...ip_end], recv[-2, 2].unpack1("S>")]
296
+ end
297
+ end
298
+
299
+ def handle_heartbeat(interval)
300
+ Async do
301
+ loop do
302
+ sleep(interval / 1000.0 * 0.9)
303
+ send_connection_message(3, Time.now.to_i)
304
+ end
305
+ end
306
+ end
307
+
308
+ def send_connection_message(opcode, data)
309
+ @connection.write(
310
+ {
311
+ op: opcode,
312
+ d: data,
313
+ }.to_json
314
+ )
315
+ @connection.flush
316
+ rescue IOError, Errno::EPIPE
317
+ return if @status == :reconnecting
318
+ @status = :reconnecting
319
+ @client.logger.warn("Voice Websocket connection closed")
320
+ @connection.close
321
+ @connect_condition = Async::Condition.new
322
+ start_receive true
323
+ end
324
+ end
325
+ end
326
+ end
@@ -1,7 +1,10 @@
1
- module Discorb::Voice
2
- #
3
- # Error for voice connection.
4
- #
5
- class VoiceError < StandardError
6
- end
7
- end
1
+ # frozen_string_literal: true
2
+ module Discorb
3
+ module Voice
4
+ #
5
+ # Error for voice connection.
6
+ #
7
+ class VoiceError < StandardError
8
+ end
9
+ end
10
+ end