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.
- checksums.yaml +4 -4
- data/.github/workflows/lint-push.yml +20 -0
- data/.github/workflows/lint.yml +16 -0
- data/.rubocop.yml +77 -0
- data/discorb-voice.gemspec +1 -1
- data/lib/discorb/voice/core.rb +326 -314
- data/lib/discorb/voice/error.rb +10 -7
- data/lib/discorb/voice/extend.rb +79 -64
- data/lib/discorb/voice/ogg.rb +121 -120
- data/lib/discorb/voice/source.rb +113 -110
- data/lib/discorb/voice/version.rb +1 -1
- data/lib/discorb/voice.rb +13 -13
- data/lib/discorb-voice.rb +2 -1
- metadata +7 -4
data/lib/discorb/voice/extend.rb
CHANGED
@@ -1,64 +1,79 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
module
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# rubocop:disable Style/Documentation
|
3
|
+
|
4
|
+
module Discorb
|
5
|
+
module Voice
|
6
|
+
# @private
|
7
|
+
module ClientVoicePrepend
|
8
|
+
attr_reader :voice_conditions
|
9
|
+
attr_reader :voice_mutexes
|
10
|
+
|
11
|
+
def initialize(*, **)
|
12
|
+
super
|
13
|
+
@voice_clients = Discorb::Dictionary.new
|
14
|
+
@voice_conditions = {}
|
15
|
+
@voice_mutexes = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def event_voice_server_update(data)
|
19
|
+
@log.debug("Received VOICE_SERVER_UPDATE")
|
20
|
+
client = Discorb::Voice::Client.new(self, data)
|
21
|
+
@voice_clients[data[:guild_id]] = client
|
22
|
+
client.connect_condition.wait
|
23
|
+
@voice_conditions[data[:guild_id]].signal client
|
24
|
+
end
|
25
|
+
|
26
|
+
def disconnect_voice(guild_id)
|
27
|
+
send_gateway(
|
28
|
+
4,
|
29
|
+
guild_id: guild_id,
|
30
|
+
channel_id: nil,
|
31
|
+
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def connect_to(channel)
|
36
|
+
Async do
|
37
|
+
@log.debug("Connecting to #{channel.id}")
|
38
|
+
send_gateway(
|
39
|
+
4,
|
40
|
+
guild_id: channel.guild.id.to_s,
|
41
|
+
channel_id: channel.id.to_s,
|
42
|
+
self_mute: channel.guild.me.voice_state&.self_mute?,
|
43
|
+
self_deaf: channel.guild.me.voice_state&.self_deaf?,
|
44
|
+
|
45
|
+
)
|
46
|
+
condition = Async::Condition.new
|
47
|
+
@voice_conditions[channel.guild.id.to_s] = condition
|
48
|
+
condition.wait
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Client
|
55
|
+
# @return [Discorb::Dictionary{String => Discorb::Voice::Client}] The voice clients.
|
56
|
+
attr_accessor :voice_clients
|
57
|
+
|
58
|
+
prepend Discorb::Voice::ClientVoicePrepend
|
59
|
+
end
|
60
|
+
|
61
|
+
class Guild
|
62
|
+
# @!attribute [r] voice_client
|
63
|
+
# @return [Discorb::Voice::Client] The voice client.
|
64
|
+
# @return [nil] If the client is not connected to the voice server.
|
65
|
+
def voice_client
|
66
|
+
@client.voice_clients[@id.to_s]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
module Connectable
|
71
|
+
def connect
|
72
|
+
Async do
|
73
|
+
@client.connect_to(self).wait
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# rubocop:enable Style/Documentation
|
data/lib/discorb/voice/ogg.rb
CHANGED
@@ -1,120 +1,121 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
#
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
#
|
36
|
-
#
|
37
|
-
#
|
38
|
-
#
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Discorb
|
3
|
+
module Voice
|
4
|
+
#
|
5
|
+
# Represents a Ogg stream.
|
6
|
+
#
|
7
|
+
class OggStream
|
8
|
+
#
|
9
|
+
# Creates a new Ogg stream.
|
10
|
+
#
|
11
|
+
# @param [IO] io The audio ogg stream.
|
12
|
+
#
|
13
|
+
def initialize(io)
|
14
|
+
@io = io
|
15
|
+
end
|
16
|
+
|
17
|
+
#
|
18
|
+
# Enumerates the packets of the Ogg stream.
|
19
|
+
#
|
20
|
+
# @return [Enumerator<Discorb::Voice::OggStream::Page::Packet>] The packets of the Ogg stream.
|
21
|
+
#
|
22
|
+
def packets
|
23
|
+
Enumerator.new do |enum|
|
24
|
+
part = +""
|
25
|
+
raw_packets.each do |packet|
|
26
|
+
part << packet.data
|
27
|
+
unless packet.partial
|
28
|
+
enum << part
|
29
|
+
part = +""
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Enumerates the raw packets of the Ogg stream.
|
37
|
+
# This may include partial packets.
|
38
|
+
#
|
39
|
+
# @return [Enumerator<Discorb::Voice::OggStream::Page::Packet>] The raw packets of the Ogg stream.
|
40
|
+
#
|
41
|
+
def raw_packets
|
42
|
+
Enumerator.new do |enum|
|
43
|
+
loop do
|
44
|
+
# p c += 1
|
45
|
+
break if @io.read(4) != "OggS"
|
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
|
121
|
+
end
|
data/lib/discorb/voice/source.rb
CHANGED
@@ -1,110 +1,113 @@
|
|
1
|
-
|
2
|
-
require "
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
#
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
args
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
end
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "open3"
|
3
|
+
require "tmpdir"
|
4
|
+
|
5
|
+
module Discorb
|
6
|
+
module Voice
|
7
|
+
#
|
8
|
+
# The source of audio data.
|
9
|
+
# @abstract
|
10
|
+
#
|
11
|
+
class Source
|
12
|
+
#
|
13
|
+
# The audio ogg stream. This MUST be implemented by subclasses.
|
14
|
+
#
|
15
|
+
# @return [#read] The audio ogg stream.
|
16
|
+
#
|
17
|
+
def io
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Clean up the source.
|
23
|
+
# This does nothing by default.
|
24
|
+
#
|
25
|
+
def cleanup
|
26
|
+
# noop
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Plays audio from a source, using FFmpeg.
|
32
|
+
# @note You must install FFmpeg and should be on the PATH.
|
33
|
+
#
|
34
|
+
class FFmpegAudio
|
35
|
+
attr_reader :stdin, :stdout, :stderr, :process
|
36
|
+
|
37
|
+
#
|
38
|
+
# Creates a new FFmpegAudio.
|
39
|
+
#
|
40
|
+
# @param [String, IO] source The source of audio data.
|
41
|
+
# @param [Integer] bitrate The bitrate of the audio.
|
42
|
+
# @param [{String => String}] extra_options Extra options for FFmpeg. This will be passed before `-i`.
|
43
|
+
# @param [{String => String}] extra_options2 Extra options for FFmpeg. This will be passed after `-i`.
|
44
|
+
#
|
45
|
+
def initialize(source, bitrate: 128, extra_options: {}, extra_options2: {})
|
46
|
+
if source.is_a?(String)
|
47
|
+
source_path = source
|
48
|
+
@tmp_path = nil
|
49
|
+
else
|
50
|
+
source_path = "#{Dir.tmpdir}/#{Process.pid}.#{source.object_id}"
|
51
|
+
@tmp_path = source_path
|
52
|
+
File.open(source_path, "wb") do |f|
|
53
|
+
while chunk = source.read(4096)
|
54
|
+
f.write(chunk)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
args = %w[ffmpeg]
|
59
|
+
extra_options.each do |key, value|
|
60
|
+
args += ["-#{key}", "#{value}"]
|
61
|
+
end
|
62
|
+
args += %W[
|
63
|
+
-i #{source_path}
|
64
|
+
-f opus
|
65
|
+
-c:a libopus
|
66
|
+
-ar 48000
|
67
|
+
-ac 2
|
68
|
+
-b:a #{bitrate}k
|
69
|
+
-loglevel warning
|
70
|
+
-map_metadata -1]
|
71
|
+
extra_options2.each do |key, value|
|
72
|
+
args += ["-#{key}", "#{value}"]
|
73
|
+
end
|
74
|
+
args += %w[pipe:1]
|
75
|
+
@stdin, @stdout, @process = Open3.popen2(*args)
|
76
|
+
end
|
77
|
+
|
78
|
+
def io
|
79
|
+
@stdout
|
80
|
+
end
|
81
|
+
|
82
|
+
#
|
83
|
+
# Kills the FFmpeg process, and closes io.
|
84
|
+
#
|
85
|
+
def cleanup
|
86
|
+
@process.kill
|
87
|
+
@stdin.close
|
88
|
+
@stdout.close
|
89
|
+
File.delete(@tmp_path) if @tmp_path
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
#
|
94
|
+
# Plays audio from a ogg file.
|
95
|
+
#
|
96
|
+
class OggAudio
|
97
|
+
attr_reader :io
|
98
|
+
|
99
|
+
#
|
100
|
+
# Opens an ogg file.
|
101
|
+
#
|
102
|
+
# @param [String, IO] src The ogg file to open, or an IO object.
|
103
|
+
#
|
104
|
+
def initialize(src)
|
105
|
+
@io = if src.is_a?(String)
|
106
|
+
File.open(src, "rb")
|
107
|
+
else
|
108
|
+
src
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
data/lib/discorb/voice.rb
CHANGED
@@ -4,26 +4,26 @@ require "discorb"
|
|
4
4
|
begin
|
5
5
|
require "rbnacl"
|
6
6
|
rescue LoadError
|
7
|
-
raise LoadError, <<~
|
8
|
-
|
9
|
-
|
10
|
-
|
7
|
+
raise LoadError, <<~ERROR, cause: nil
|
8
|
+
Could not load libsodium library.
|
9
|
+
Follow the instructions at https://github.com/discorb-lib/discorb-voice#install-libsodium
|
10
|
+
ERROR
|
11
11
|
end
|
12
12
|
require "open3"
|
13
13
|
begin
|
14
14
|
ffmpeg_version = Open3.capture2e("ffmpeg -version")[0]
|
15
15
|
rescue Errno::ENOENT
|
16
|
-
raise LoadError, <<~
|
17
|
-
|
18
|
-
|
19
|
-
|
16
|
+
raise LoadError, <<~ERROR, cause: nil
|
17
|
+
Could not find ffmpeg.
|
18
|
+
Follow the instructions at https://github.com/discorb-lib/discorb-voice#install-ffmpeg
|
19
|
+
ERROR
|
20
20
|
else
|
21
|
-
line = ffmpeg_version.split("\n").find { |
|
21
|
+
line = ffmpeg_version.split("\n").find { |l| l.start_with?("configuration: ") }
|
22
22
|
unless line.include? "--enable-libopus"
|
23
|
-
raise LoadError, <<~
|
24
|
-
|
25
|
-
|
26
|
-
|
23
|
+
raise LoadError, <<~ERROR, cause: nil
|
24
|
+
Your ffmpeg version does not support opus.
|
25
|
+
Install ffmpeg with opus support.
|
26
|
+
ERROR
|
27
27
|
end
|
28
28
|
end
|
29
29
|
require_relative "voice/version"
|
data/lib/discorb-voice.rb
CHANGED
@@ -1 +1,2 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "discorb/voice"
|