mb-sound-jackffi 0.0.2.usegit
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 +7 -0
- data/.gitignore +14 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +2 -0
- data/LICENSE +22 -0
- data/README.md +75 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/invert.rb +18 -0
- data/bin/loopback.rb +17 -0
- data/lib/mb-sound-jackffi.rb +1 -0
- data/lib/mb/sound/jack_ffi.rb +528 -0
- data/mb-sound-jackffi.gemspec +33 -0
- metadata +114 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f83396720dca4e6728fb1a7a245544aaa5c4eab27b2b769875838f9da7ed26b0
|
4
|
+
data.tar.gz: e58f921b2d1523158ffb0cd9390f0365656c224b00ddb9f5ec808fac31e9a448
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 24e03a3967758a8fee6d05e9300294200cfe4f812ad239ecc6054ea2d1ac099ba538776b442dfc8c4cadba377ebc18c58701b4b3474252339f8aac278abacef9
|
7
|
+
data.tar.gz: '01012439f2c0eeeb1e3b6f76b1a02faf1f480e535da55beb96bf8f909442acb4fcc8b9e67a95b8657d17ef2dcb3c8a3b394d4cd1579a63f32d3765492f36819d'
|
data/.gitignore
ADDED
data/.ruby-gemset
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
mb-jack-ffi
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.7.2
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2020, Mike Bourgeous
|
2
|
+
All rights reserved.
|
3
|
+
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
6
|
+
|
7
|
+
* Redistributions of source code must retain the above copyright notice,
|
8
|
+
this list of conditions and the following disclaimer.
|
9
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
10
|
+
this list of conditions and the following disclaimer in the documentation
|
11
|
+
and/or other materials provided with the distribution.
|
12
|
+
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
14
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
15
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
17
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
18
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
20
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
21
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
22
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
# mb-sound-jackffi
|
2
|
+
|
3
|
+
An *UNSTABLE* (as in it occasionally crashes, hangs, or drops audio) Ruby FFI
|
4
|
+
interface for the [JACK Audio Connection Kit][1]. I've only tested this on
|
5
|
+
Linux.
|
6
|
+
|
7
|
+
This FFI interface can be used by my [mb-sound][2] gem, a companion library to
|
8
|
+
an [educational video series I'm making about sound][0].
|
9
|
+
|
10
|
+
## Rationale
|
11
|
+
|
12
|
+
The [mb-sound][2] gem uses standalone command line tools for playing and
|
13
|
+
recording audio, via `popen`. This works well enough, but has high and
|
14
|
+
unpredictable latency. Some of the things I want to do with audio and video
|
15
|
+
will require tighter control over latency and synchronization between audio and
|
16
|
+
video.
|
17
|
+
|
18
|
+
There was another FFI interface for JACK, but the homepage no longer exists and
|
19
|
+
it hasn't been updated in ages.
|
20
|
+
|
21
|
+
## Installation
|
22
|
+
|
23
|
+
There are some base packages you'll probably want first, though they might not
|
24
|
+
be required for your system:
|
25
|
+
|
26
|
+
```bash
|
27
|
+
# Debian-/Ubuntu-based Linux (macOS/Arch/CentOS will differ)
|
28
|
+
sudo apt-get install libffi-dev
|
29
|
+
```
|
30
|
+
|
31
|
+
Then you'll want to install Ruby 2.7.2 (I recommend [RVM](https://rvm.io)).
|
32
|
+
|
33
|
+
Finally, you can add the Gem (via Git) to your Gemfile:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
# your-project/Gemfile
|
37
|
+
gem 'mb-sound-jackffi', git: 'git@github.com:mike-bourgeous/mb-sound-jackffi.git'
|
38
|
+
```
|
39
|
+
|
40
|
+
## Examples
|
41
|
+
|
42
|
+
The `MB::Sound::JackFFI` class represents a connection to a JACK server with a
|
43
|
+
specific client name. Its `input` and `output` instance methods will create
|
44
|
+
input or output ports on the JACK client.
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
require 'mb-sound-jackffi' # Or 'mb/sound/jack_ffi'
|
48
|
+
|
49
|
+
# Enjoy silence
|
50
|
+
out = MB::Sound::JackFFI[client_name: 'my app'].output(port_names: ['left', 'right'])
|
51
|
+
loop do
|
52
|
+
out.write([Numo::SFloat.zeros(out.buffer_size)] * out.channels)
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
Also check out `bin/loopback.rb` and `bin/invert.rb`.
|
57
|
+
|
58
|
+
## Testing
|
59
|
+
|
60
|
+
Testing is mostly manual. I might eventually figure out a way to install jackd
|
61
|
+
in a CI environment and run loopback tests. If that happens, you will be able
|
62
|
+
to run the integrated test suite with `rspec`, but not yet.
|
63
|
+
|
64
|
+
## Contributing
|
65
|
+
|
66
|
+
Pull requests are welcome.
|
67
|
+
|
68
|
+
## License
|
69
|
+
|
70
|
+
This project is released under a 2-clause BSD license. See the LICENSE file.
|
71
|
+
|
72
|
+
|
73
|
+
[0]: https://www.youtube.com/playlist?list=PLpRqC8LaADXnwve3e8gI239eDNRO3Nhya
|
74
|
+
[1]: https://jackaudio.org
|
75
|
+
[2]: https://github.com/mike-bourgeous/mb-sound
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "mb-sound-jackffi"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/invert.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Passes audio directly from input ports to output ports, with a 180 degree
|
3
|
+
# phase inversion.
|
4
|
+
|
5
|
+
require "bundler/setup"
|
6
|
+
require 'mb-sound-jackffi'
|
7
|
+
|
8
|
+
channels = ARGV[0]&.to_i || 2
|
9
|
+
puts "Running with #{channels} channels"
|
10
|
+
|
11
|
+
jack = MB::Sound::JackFFI[client_name: 'invert']
|
12
|
+
|
13
|
+
input = jack.input(channels: channels)
|
14
|
+
output = jack.output(channels: channels)
|
15
|
+
|
16
|
+
loop do
|
17
|
+
output.write(input.read.map { |c| -(c.inplace) })
|
18
|
+
end
|
data/bin/loopback.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Passes audio directly from input ports to output ports unmodified.
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require 'mb-sound-jackffi'
|
6
|
+
|
7
|
+
channels = ARGV[0]&.to_i || 2
|
8
|
+
puts "Running with #{channels} channels"
|
9
|
+
|
10
|
+
jack = MB::Sound::JackFFI[client_name: 'loopback']
|
11
|
+
|
12
|
+
input = jack.input(channels: channels)
|
13
|
+
output = jack.output(channels: channels)
|
14
|
+
|
15
|
+
loop do
|
16
|
+
output.write(input.read)
|
17
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'mb/sound/jack_ffi'
|
@@ -0,0 +1,528 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'numo/narray'
|
3
|
+
require 'ffi'
|
4
|
+
|
5
|
+
module MB
|
6
|
+
module Sound
|
7
|
+
# This is the base connection to JACK, representing a client_name/server_name
|
8
|
+
# pair. Multiple input and output instances may be created for a single
|
9
|
+
# client, which will show up as ports on a single JACK client.
|
10
|
+
#
|
11
|
+
# Examples:
|
12
|
+
#
|
13
|
+
# # Create two unconnected input ports
|
14
|
+
# MB::Sound::JackFFI[].input(channels: 2)
|
15
|
+
#
|
16
|
+
# # TODO: examples, and make sure they work
|
17
|
+
#
|
18
|
+
#
|
19
|
+
#
|
20
|
+
# TODO: Maybe split into separate files
|
21
|
+
# TODO: Maybe support environment variables for client name, server name, port names, etc.
|
22
|
+
# TODO: Support connecting ports after creating them
|
23
|
+
class JackFFI
|
24
|
+
# The default size of the buffer queues for communicating between Ruby and
|
25
|
+
# JACK. This is separate from JACK's own internal buffers. The
|
26
|
+
# :queue_size parameter to #input and #output allows overriding these
|
27
|
+
# defaults.
|
28
|
+
INPUT_QUEUE_SIZE = 2
|
29
|
+
OUTPUT_QUEUE_SIZE = 2
|
30
|
+
|
31
|
+
# Raw FFI interface to JACK. Don't use this directly; instead use
|
32
|
+
# JackFFIInput and JackFFIOutput, which will use JackFFI[] to retrieve a
|
33
|
+
# connection.
|
34
|
+
#
|
35
|
+
# References:
|
36
|
+
#
|
37
|
+
# https://github.com/jackaudio/jack2/blob/b2ba349a4eb4c9a5a51dbc7a7af487851ade8cba/example-clients/simple_client.c
|
38
|
+
# https://jackaudio.org/api/simple__client_8c.html#a0ddf1224851353fc92bfbff6f499fa97
|
39
|
+
# https://github.com/ffi/ffi/blob/6d31bf845e6527cc7f67d236a95c0161df969b12/lib/ffi/library.rb#L515
|
40
|
+
# https://github.com/ffi/ffi/blob/f7c5b607e07b7f00e3c7a46f427c76cad65fbb78/ext/ffi_c/FunctionInfo.c
|
41
|
+
# https://github.com/ffi/ffi/wiki/Pointers
|
42
|
+
module Jack
|
43
|
+
extend FFI::Library
|
44
|
+
ffi_lib ['jack', 'libjack.so.0.1.0', 'libjack.so.0']
|
45
|
+
|
46
|
+
AUDIO_TYPE = "32 bit float mono audio"
|
47
|
+
|
48
|
+
@blocking = true
|
49
|
+
|
50
|
+
bitmask :jack_options_t, [
|
51
|
+
:JackNoStartServer,
|
52
|
+
:JackUseExactName,
|
53
|
+
:JackServerName,
|
54
|
+
:JackLoadName,
|
55
|
+
:JackLoadInit,
|
56
|
+
:JackSessionID,
|
57
|
+
]
|
58
|
+
|
59
|
+
bitmask :jack_status_t, [
|
60
|
+
:JackFailure,
|
61
|
+
:JackInvalidOption,
|
62
|
+
:JackNameNotUnique,
|
63
|
+
:JackServerStarted,
|
64
|
+
:JackServerFailed,
|
65
|
+
:JackServerError,
|
66
|
+
:JackNoSuchClient,
|
67
|
+
:JackLoadFailure,
|
68
|
+
:JackInitFailure,
|
69
|
+
:JackShmFailure,
|
70
|
+
:JackVersionError,
|
71
|
+
:JackBackendError,
|
72
|
+
:JackClientZombie,
|
73
|
+
]
|
74
|
+
|
75
|
+
# jack_port_register accepts "unsigned long" for some reason, so make sure this is the right size
|
76
|
+
bitmask FFI::Type::ULONG, :jack_port_flags, [
|
77
|
+
:JackPortIsInput,
|
78
|
+
:JackPortIsOutput,
|
79
|
+
:JackPortIsPhysical,
|
80
|
+
:JackPortCanMonitor,
|
81
|
+
:JackPortIsTerminal,
|
82
|
+
]
|
83
|
+
|
84
|
+
class JackStatusWrapper < FFI::Struct
|
85
|
+
layout :status, :jack_status_t
|
86
|
+
end
|
87
|
+
|
88
|
+
typedef :uint32_t, :jack_nframes_t
|
89
|
+
|
90
|
+
# Client management functions
|
91
|
+
# Note: jack_deactivate, or if you don't call that, jack_client_close,
|
92
|
+
# cause Ruby/FFI to perform invalid reads often, and sometimes crash.
|
93
|
+
# It's probably okay to leave the connection to JACK open and let the
|
94
|
+
# OS clean up when the application exits.
|
95
|
+
typedef :pointer, :jack_client
|
96
|
+
attach_function :jack_client_open, [:string, :jack_options_t, JackStatusWrapper.by_ref, :varargs], :jack_client
|
97
|
+
attach_function :jack_client_close, [:jack_client], :int
|
98
|
+
attach_function :jack_get_client_name, [:jack_client], :string
|
99
|
+
attach_function :jack_activate, [:jack_client], :int
|
100
|
+
attach_function :jack_deactivate, [:jack_client], :int
|
101
|
+
|
102
|
+
# Server status functions
|
103
|
+
attach_function :jack_get_buffer_size, [:jack_client], :jack_nframes_t
|
104
|
+
attach_function :jack_get_sample_rate, [:jack_client], :jack_nframes_t
|
105
|
+
|
106
|
+
# Callback functions
|
107
|
+
typedef :pointer, :jack_user_data
|
108
|
+
callback :jack_process_callback, [:jack_nframes_t, :jack_user_data], :void
|
109
|
+
callback :jack_shutdown_callback, [:jack_user_data], :void
|
110
|
+
attach_function :jack_set_process_callback, [:jack_client, :jack_process_callback, :jack_user_data], :int
|
111
|
+
attach_function :jack_on_shutdown, [:jack_client, :jack_shutdown_callback, :jack_user_data], :void
|
112
|
+
|
113
|
+
# Port management functions
|
114
|
+
typedef :pointer, :jack_port
|
115
|
+
attach_function :jack_port_register, [:jack_client, :string, :string, :jack_port_flags, :ulong], :jack_port
|
116
|
+
attach_function :jack_port_unregister, [:jack_client, :jack_port], :int
|
117
|
+
attach_function :jack_port_get_buffer, [:jack_port, :jack_nframes_t], :pointer
|
118
|
+
end
|
119
|
+
|
120
|
+
# Returned by JackFFI#input. E.g. use JackFFI[client_name: 'my
|
121
|
+
# client'].input(channels: 2) to get two input ports on the client.
|
122
|
+
class Input
|
123
|
+
extend Forwardable
|
124
|
+
|
125
|
+
def_delegators :@jack_ffi, :buffer_size, :rate
|
126
|
+
|
127
|
+
attr_reader :channels, :ports
|
128
|
+
|
129
|
+
# Called by JackFFI to initialize an audio input handle. You generally
|
130
|
+
# won't use this constructor directly. Instead use JackFFI#input.
|
131
|
+
#
|
132
|
+
# +:jack_ffi+ - The JackFFI instance that contains this input.
|
133
|
+
# +:ports+ - An Array of JACK port names.
|
134
|
+
def initialize(jack_ffi:, ports:)
|
135
|
+
@jack_ffi = jack_ffi
|
136
|
+
@ports = ports
|
137
|
+
@channels = ports.length
|
138
|
+
end
|
139
|
+
|
140
|
+
# Removes this input object's ports from the client.
|
141
|
+
def close
|
142
|
+
@jack_ffi.remove(self)
|
143
|
+
end
|
144
|
+
|
145
|
+
# Reads one #buffer_size buffer of frames as an Array of Numo::SFloat.
|
146
|
+
# Any frame count parameter is ignored, as JACK operates in lockstep with
|
147
|
+
# a fixed buffer size. The returned Array will have one element for each
|
148
|
+
# input port.
|
149
|
+
def read(_ignored = nil)
|
150
|
+
@jack_ffi.read_ports(@ports)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Returned by JackFFI#output. E.g. use JackFFI[client_name: 'my
|
155
|
+
# client'].output(channels: 2) to get two output ports on the client.
|
156
|
+
class Output
|
157
|
+
extend Forwardable
|
158
|
+
|
159
|
+
def_delegators :@jack_ffi, :buffer_size, :rate
|
160
|
+
|
161
|
+
attr_reader :channels, :ports
|
162
|
+
|
163
|
+
# Called by JackFFI to initialize an audio output handle. You generally
|
164
|
+
# won't use this constructor directly. Instead use JackFFI#output.
|
165
|
+
#
|
166
|
+
# +:jack_ffi+ - The JackFFI instance that contains this output.
|
167
|
+
# +:ports+ - An Array of JACK port names.
|
168
|
+
def initialize(jack_ffi:, ports:)
|
169
|
+
@jack_ffi = jack_ffi
|
170
|
+
@ports = ports
|
171
|
+
@channels = ports.length
|
172
|
+
end
|
173
|
+
|
174
|
+
# Removes this output object's ports from the client.
|
175
|
+
def close
|
176
|
+
@jack_ffi.remove(self)
|
177
|
+
end
|
178
|
+
|
179
|
+
# Writes the given Array of data (Numo::SFloat recommended). The Array
|
180
|
+
# should contain one element for each output port.
|
181
|
+
def write(data)
|
182
|
+
@jack_ffi.write_ports(@ports, data)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
# Retrieves a base client instance for the given client name and server
|
187
|
+
# name.
|
188
|
+
#
|
189
|
+
# Note that if there is already a client with the given name connected to
|
190
|
+
# JACK, the client name will be changed by JACK. Use JackFFI#client_name
|
191
|
+
# to get the true client name if needed.
|
192
|
+
def self.[](client_name: 'ruby', server_name: nil)
|
193
|
+
@instances ||= {}
|
194
|
+
@instances[name] ||= new(client_name: client_name, server_name: server_name)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Internal API called by JackFFI#close. Removes an instance of JackFFI
|
198
|
+
# that is no longer functioning, so that future calls to JackFFI[] will
|
199
|
+
# create a new connection.
|
200
|
+
def self.remove(jack_ffi)
|
201
|
+
@instances.reject! { |k, v| v == jack_ffi }
|
202
|
+
end
|
203
|
+
|
204
|
+
attr_reader :client_name, :server_name, :buffer_size, :rate
|
205
|
+
|
206
|
+
# Generally you don't need to create a JackFFI instance yourself. Instead,
|
207
|
+
# use JackFFI[] (the array indexing operator) to retrieve a connection, and
|
208
|
+
# JackFFI#input and JackFFI#output to get an input or output object with a
|
209
|
+
# read or write method.
|
210
|
+
#
|
211
|
+
# You might want to use this class directly if you want to override the
|
212
|
+
# #process method to run custom code in the JACK realtime thread instead of
|
213
|
+
# reading and writing data through JackFFIInput and JackFFIOutput.
|
214
|
+
#
|
215
|
+
# Every JackFFI instance lives until the Ruby VM exits, because JACK's
|
216
|
+
# callback APIs cause invalid memory accesses (and thus crashes) in the FFI
|
217
|
+
# library when the JACK C client is shut down.
|
218
|
+
def initialize(client_name: 'ruby', server_name: nil)
|
219
|
+
@client_name = client_name || 'ruby'
|
220
|
+
@server_name = server_name
|
221
|
+
|
222
|
+
@run = true
|
223
|
+
|
224
|
+
# Port maps use the port name as key, with a Hash as value. See #create_io.
|
225
|
+
@input_ports = {}
|
226
|
+
@output_ports = {}
|
227
|
+
|
228
|
+
# Montonically increasing indices used to number prefix-named ports.
|
229
|
+
@port_indices = {
|
230
|
+
JackPortIsInput: 0,
|
231
|
+
JackPortIsOutput: 0,
|
232
|
+
}
|
233
|
+
|
234
|
+
@init_mutex = Mutex.new
|
235
|
+
|
236
|
+
@init_mutex.synchronize {
|
237
|
+
status = Jack::JackStatusWrapper.new
|
238
|
+
@client = Jack.jack_client_open(
|
239
|
+
client_name,
|
240
|
+
server_name ? :JackServerName : 0,
|
241
|
+
status,
|
242
|
+
:string, server_name
|
243
|
+
)
|
244
|
+
|
245
|
+
if @client.nil? || @client.null?
|
246
|
+
raise "Failed to open JACK client; status: #{status[:status]}"
|
247
|
+
end
|
248
|
+
|
249
|
+
if status[:status].include?(:JackServerStarted)
|
250
|
+
log "Server was started as a result of trying to connect"
|
251
|
+
end
|
252
|
+
|
253
|
+
@client_name = Jack.jack_get_client_name(@client)
|
254
|
+
if status[:status].include?(:JackNameNotUnique)
|
255
|
+
log "Server assigned a new client name (replacing #{client_name.inspect}): #{@client_name.inspect}"
|
256
|
+
end
|
257
|
+
|
258
|
+
@buffer_size = Jack.jack_get_buffer_size(@client)
|
259
|
+
@rate = Jack.jack_get_sample_rate(@client)
|
260
|
+
@zero = Numo::SFloat.zeros(@buffer_size)
|
261
|
+
|
262
|
+
@process_handle = method(:process) # Assigned to variable to prevent GC
|
263
|
+
result = Jack.jack_set_process_callback(@client, @process_handle, nil)
|
264
|
+
raise "Error setting JACK process callback: #{result}" if result != 0
|
265
|
+
|
266
|
+
@shutdown_handle = method(:shutdown)
|
267
|
+
Jack.jack_on_shutdown(@client, @shutdown_handle, nil)
|
268
|
+
|
269
|
+
# TODO: Maybe set a buffer size callback
|
270
|
+
|
271
|
+
result = Jack.jack_activate(@client)
|
272
|
+
raise "Error activating JACK client: #{result}" if result != 0
|
273
|
+
}
|
274
|
+
|
275
|
+
rescue Exception
|
276
|
+
close if @client
|
277
|
+
raise
|
278
|
+
end
|
279
|
+
|
280
|
+
# Returns a new JackFFI::Input and creates corresponding new input ports on
|
281
|
+
# the JACK client.
|
282
|
+
#
|
283
|
+
# If +:port_names+ is a String, then it is used as a prefix to create
|
284
|
+
# +channels+ numbered ports. If +:port_names+ is an Array of Strings, then
|
285
|
+
# those port names will be created directly without numbering.
|
286
|
+
#
|
287
|
+
# Port names must be unique.
|
288
|
+
#
|
289
|
+
# +:channels+ - The number of ports to create if +:port_names+ is a String.
|
290
|
+
# +:port_names+ - A String (without a trailing underscore) to create
|
291
|
+
# prefixed and numbered ports, or an Array of Strings to
|
292
|
+
# create a list of ports directly by name.
|
293
|
+
# +:connections+ - TODO (maybe String client name, maybe list of ports)
|
294
|
+
# +:queue_size+ - Optional: number of audio buffers to hold between Ruby
|
295
|
+
# and the JACK thread (higher means more latency but less
|
296
|
+
# risk of dropouts). Default is INPUT_QUEUE_SIZE. Sane
|
297
|
+
# values range from 1 to 4.
|
298
|
+
def input(channels: nil, port_names: 'in', connections: nil, queue_size: nil)
|
299
|
+
create_io(
|
300
|
+
channels: channels,
|
301
|
+
port_names: port_names,
|
302
|
+
connections: connections,
|
303
|
+
portmap: @input_ports,
|
304
|
+
jack_direction: :JackPortIsInput,
|
305
|
+
queue_size: queue_size || INPUT_QUEUE_SIZE,
|
306
|
+
io_class: Input
|
307
|
+
)
|
308
|
+
end
|
309
|
+
|
310
|
+
# Returns a new JackFFI::Input and creates corresponding new input ports on
|
311
|
+
# the JACK client.
|
312
|
+
#
|
313
|
+
# Parameters are the same as for #input, with the default for +:queue_size+
|
314
|
+
# being OUTPUT_QUEUE_SIZE.
|
315
|
+
def output(channels: nil, port_names: 'out', connections: nil, queue_size: nil)
|
316
|
+
create_io(
|
317
|
+
channels: channels,
|
318
|
+
port_names: port_names,
|
319
|
+
connections: connections,
|
320
|
+
portmap: @output_ports,
|
321
|
+
jack_direction: :JackPortIsOutput,
|
322
|
+
queue_size: queue_size || OUTPUT_QUEUE_SIZE,
|
323
|
+
io_class: Output
|
324
|
+
)
|
325
|
+
end
|
326
|
+
|
327
|
+
# Internal API used by JackFFI::Input#close and JackFFI::Output#close.
|
328
|
+
# Removes all of a given input's or output's ports from the client.
|
329
|
+
def remove(input_or_output)
|
330
|
+
case input_or_output
|
331
|
+
when Input
|
332
|
+
portmap = @input_ports
|
333
|
+
|
334
|
+
when Output
|
335
|
+
portmap = @output_ports
|
336
|
+
end
|
337
|
+
|
338
|
+
input_or_output.ports.each do |name|
|
339
|
+
port_info = portmap.delete(name)
|
340
|
+
if port_info
|
341
|
+
result = Jack.jack_port_unregister(@client, port_info[:port_id])
|
342
|
+
log "Error unregistering port #{port_info[:name]}: #{result}" if result != 0
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# This generally doesn't need to be called. This method stops background
|
348
|
+
# processing, but the JACK thread continues to run because stopping it
|
349
|
+
# often causes Ruby to crash with SIGSEGV (Valgrind shows invalid reads
|
350
|
+
# when FFI invokes the process callback after jack_deactivate starts).
|
351
|
+
def close
|
352
|
+
@init_mutex&.synchronize {
|
353
|
+
@run = false
|
354
|
+
JackFFI.remove(self)
|
355
|
+
}
|
356
|
+
end
|
357
|
+
|
358
|
+
# Writes the given +data+ to the ports represented by the given Array of
|
359
|
+
# port IDs. Used internally by JackFFI::Output.
|
360
|
+
def write_ports(ports, data)
|
361
|
+
raise "JACK connection is closed" unless @run
|
362
|
+
|
363
|
+
check_for_processing_error
|
364
|
+
|
365
|
+
# TODO: Maybe support different write sizes by writing into big ring buffers
|
366
|
+
raise 'Must supply the same number of data arrays as ports' unless ports.length == data.length
|
367
|
+
raise "Output buffer must be #{@buffer_size} samples long" unless data.all? { |c| c.length == @buffer_size }
|
368
|
+
|
369
|
+
ports.each_with_index do |name, idx|
|
370
|
+
@output_ports[name][:queue].push(data[idx])
|
371
|
+
end
|
372
|
+
|
373
|
+
nil
|
374
|
+
end
|
375
|
+
|
376
|
+
# Reads one buffer_size chunk of data for the given Array of port IDs.
|
377
|
+
# This is generally for internal use by the JackFFI::Input class.
|
378
|
+
def read_ports(ports)
|
379
|
+
raise "JACK connection is closed" unless @run
|
380
|
+
|
381
|
+
check_for_processing_error
|
382
|
+
|
383
|
+
ports.map { |name|
|
384
|
+
@input_ports[name][:queue].pop
|
385
|
+
}
|
386
|
+
end
|
387
|
+
|
388
|
+
private
|
389
|
+
|
390
|
+
# Common code for creating ports shared by #input and #output. API subject to change.
|
391
|
+
def create_io(channels:, port_names:, connections:, portmap:, jack_direction:, queue_size:, io_class:)
|
392
|
+
raise "Queue size must be positive" if queue_size <= 0
|
393
|
+
|
394
|
+
case port_names
|
395
|
+
when Array
|
396
|
+
raise "Do not specify a channel count when an array of port names is given" if channels
|
397
|
+
|
398
|
+
when String
|
399
|
+
raise "Channel count must be given for prefix-named ports" unless channels.is_a?(Integer)
|
400
|
+
|
401
|
+
port_names = channels.times.map { |c|
|
402
|
+
"#{port_names}_#{@port_indices[jack_direction]}".tap { @port_indices[jack_direction] += 1 }
|
403
|
+
}
|
404
|
+
|
405
|
+
else
|
406
|
+
raise "Pass a String or an Array of Strings for :port_names (received #{port_names.class})"
|
407
|
+
end
|
408
|
+
|
409
|
+
port_names.each do |name|
|
410
|
+
raise "Port #{name} already exists" if portmap.include?(name)
|
411
|
+
end
|
412
|
+
|
413
|
+
# Use a separate array so that ports can be cleaned up if a later port
|
414
|
+
# fails to initialize.
|
415
|
+
ports = []
|
416
|
+
|
417
|
+
# TODO: if having one SizedQueue per port is too slow, maybe have one SizedQueue per IO object
|
418
|
+
|
419
|
+
io = io_class.new(jack_ffi: self, ports: port_names)
|
420
|
+
|
421
|
+
port_names.each do |name|
|
422
|
+
port_id = Jack.jack_port_register(@client, name, Jack::AUDIO_TYPE, jack_direction, 0)
|
423
|
+
if port_id.nil? || port_id.null?
|
424
|
+
ports.each do |p|
|
425
|
+
Jack.jack_port_unregister(@client, p[:port])
|
426
|
+
end
|
427
|
+
|
428
|
+
raise "Error creating port #{name}"
|
429
|
+
end
|
430
|
+
|
431
|
+
ports << {
|
432
|
+
name: name,
|
433
|
+
io: io,
|
434
|
+
port_id: port_id,
|
435
|
+
queue: SizedQueue.new(queue_size),
|
436
|
+
drops: 0
|
437
|
+
}
|
438
|
+
end
|
439
|
+
|
440
|
+
ports.each do |port_info|
|
441
|
+
portmap[port_info[:name]] = port_info
|
442
|
+
end
|
443
|
+
|
444
|
+
# TODO Connections
|
445
|
+
raise NotImplementedError if connections
|
446
|
+
|
447
|
+
io
|
448
|
+
end
|
449
|
+
|
450
|
+
def log(msg)
|
451
|
+
puts "JackFFI(#{@server_name}/#{@client_name}): #{msg}"
|
452
|
+
end
|
453
|
+
|
454
|
+
def check_for_processing_error
|
455
|
+
if @processing_error
|
456
|
+
# Re-raise the error so we can set it as the cause on another error
|
457
|
+
e = @processing_error
|
458
|
+
@processing_error = nil
|
459
|
+
begin
|
460
|
+
raise e
|
461
|
+
rescue
|
462
|
+
raise "An error occurred in the processing thread: #{e.message}"
|
463
|
+
end
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
# Called by JACK within its realtime thread when new audio data should be
|
468
|
+
# read and written. Only the bare minimum of processing should be done
|
469
|
+
# here (and really, Ruby itself is not ideal for realtime use).
|
470
|
+
def process(frames, user_data)
|
471
|
+
@init_mutex&.synchronize {
|
472
|
+
return unless @run && @client && @input_ports && @output_ports
|
473
|
+
|
474
|
+
@input_ports.each do |name, port_info|
|
475
|
+
# FIXME: Avoid allocation in this function; use a buffer pool or something
|
476
|
+
buf = Jack.jack_port_get_buffer(port_info[:port_id], frames)
|
477
|
+
|
478
|
+
queue = port_info[:queue]
|
479
|
+
|
480
|
+
if queue.length == queue.max
|
481
|
+
log "Input port #{name} buffer queue is full" if port_info[:drops] == 0
|
482
|
+
queue.pop rescue nil
|
483
|
+
port_info[:drops] += 1
|
484
|
+
else
|
485
|
+
log "Input port #{name} buffer queue recovered after #{port_info[:drops]} dropped buffers" if port_info[:drops] > 0
|
486
|
+
port_info[:drops] = 0
|
487
|
+
end
|
488
|
+
|
489
|
+
queue.push(Numo::SFloat.from_binary(buf.read_bytes(frames * 4)), true)
|
490
|
+
end
|
491
|
+
|
492
|
+
@output_ports.each do |name, port_info|
|
493
|
+
queue = port_info[:queue]
|
494
|
+
data = queue.pop(true) rescue nil unless queue.empty?
|
495
|
+
if data.nil?
|
496
|
+
log "Output port #{name} ran out of data to write" if port_info[:drops] == 0
|
497
|
+
port_info[:drops] += 1
|
498
|
+
data = @zero
|
499
|
+
else
|
500
|
+
log "Output port #{name} recovered after #{port_info[:drops]} dropped buffers" if port_info[:drops] > 0
|
501
|
+
port_info[:drops] = 0
|
502
|
+
end
|
503
|
+
|
504
|
+
buf = Jack.jack_port_get_buffer(port_info[:port_id], frames)
|
505
|
+
buf.write_bytes(data.to_binary)
|
506
|
+
end
|
507
|
+
}
|
508
|
+
rescue => e
|
509
|
+
@processing_error = e
|
510
|
+
log "Error processing: #{e}"
|
511
|
+
end
|
512
|
+
|
513
|
+
# Called when either the JACK server is shut down, or a severe enough
|
514
|
+
# client error occurs that JACK kicks the client out of the server.
|
515
|
+
def shutdown(user_data)
|
516
|
+
return unless @client
|
517
|
+
|
518
|
+
log "JACK is shutting down"
|
519
|
+
@run = false
|
520
|
+
|
521
|
+
# Can't close JACK from within its own shutdown callback
|
522
|
+
Thread.new do sleep 0.25; close end
|
523
|
+
rescue
|
524
|
+
nil
|
525
|
+
end
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "mb-sound-jackffi"
|
5
|
+
spec.version = '0.0.2.usegit'
|
6
|
+
spec.authors = ["Mike Bourgeous"]
|
7
|
+
spec.email = ["mike@mikebourgeous.com"]
|
8
|
+
|
9
|
+
spec.summary = %q{A Ruby FFI interface to the JACK Audio Connection Kit}
|
10
|
+
spec.homepage = "https://github.com/mike-bourgeous/mb-sound-jackffi"
|
11
|
+
|
12
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
13
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
14
|
+
if spec.respond_to?(:metadata)
|
15
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
17
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
18
|
+
else
|
19
|
+
raise "RubyGems 2.0 or newer is required to protect against " \
|
20
|
+
"public gem pushes."
|
21
|
+
end
|
22
|
+
|
23
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
f.match(%r{^(test|spec|features)/})
|
25
|
+
end
|
26
|
+
spec.require_paths = ["lib"]
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 2.1.0"
|
29
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
30
|
+
|
31
|
+
spec.add_runtime_dependency 'numo-narray', '~> 0.9.1'
|
32
|
+
spec.add_runtime_dependency 'ffi', '~> 1.13.0'
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mb-sound-jackffi
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2.usegit
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mike Bourgeous
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-12-17 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 2.1.0
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 2.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '13.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '13.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: numo-narray
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.9.1
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.9.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: ffi
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 1.13.0
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 1.13.0
|
69
|
+
description:
|
70
|
+
email:
|
71
|
+
- mike@mikebourgeous.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- ".ruby-gemset"
|
78
|
+
- ".ruby-version"
|
79
|
+
- Gemfile
|
80
|
+
- LICENSE
|
81
|
+
- README.md
|
82
|
+
- Rakefile
|
83
|
+
- bin/console
|
84
|
+
- bin/invert.rb
|
85
|
+
- bin/loopback.rb
|
86
|
+
- lib/mb-sound-jackffi.rb
|
87
|
+
- lib/mb/sound/jack_ffi.rb
|
88
|
+
- mb-sound-jackffi.gemspec
|
89
|
+
homepage: https://github.com/mike-bourgeous/mb-sound-jackffi
|
90
|
+
licenses: []
|
91
|
+
metadata:
|
92
|
+
allowed_push_host: https://rubygems.org
|
93
|
+
homepage_uri: https://github.com/mike-bourgeous/mb-sound-jackffi
|
94
|
+
source_code_uri: https://github.com/mike-bourgeous/mb-sound-jackffi
|
95
|
+
post_install_message:
|
96
|
+
rdoc_options: []
|
97
|
+
require_paths:
|
98
|
+
- lib
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
105
|
+
requirements:
|
106
|
+
- - ">"
|
107
|
+
- !ruby/object:Gem::Version
|
108
|
+
version: 1.3.1
|
109
|
+
requirements: []
|
110
|
+
rubygems_version: 3.1.4
|
111
|
+
signing_key:
|
112
|
+
specification_version: 4
|
113
|
+
summary: A Ruby FFI interface to the JACK Audio Connection Kit
|
114
|
+
test_files: []
|