discorb-voice 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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