mb-sound-jackffi 0.0.2.usegit
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|