ffi-sndfile 0.1.0
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/CHANGELOG.md +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +230 -0
- data/lib/ffi-sndfile.rb +5 -0
- data/lib/sndfile/commands.rb +99 -0
- data/lib/sndfile/constants.rb +264 -0
- data/lib/sndfile/error.rb +80 -0
- data/lib/sndfile/format.rb +124 -0
- data/lib/sndfile/formats.rb +60 -0
- data/lib/sndfile/info.rb +27 -0
- data/lib/sndfile/library_version.rb +52 -0
- data/lib/sndfile/native/enums.rb +64 -0
- data/lib/sndfile/native/functions.rb +176 -0
- data/lib/sndfile/native/handle.rb +23 -0
- data/lib/sndfile/native/structs.rb +194 -0
- data/lib/sndfile/native.rb +28 -0
- data/lib/sndfile/sound_file.rb +289 -0
- data/lib/sndfile/strings.rb +74 -0
- data/lib/sndfile/version.rb +5 -0
- data/lib/sndfile/virtual_io.rb +86 -0
- data/lib/sndfile.rb +44 -0
- metadata +80 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SndFile
|
|
4
|
+
# FFI::Struct definitions for the structs in sndfile.h, in the order of
|
|
5
|
+
# that header. Field names follow the C names.
|
|
6
|
+
module Native
|
|
7
|
+
# Helpers for structs with fixed-size char arrays: read them as Ruby
|
|
8
|
+
# strings (up to the first NUL) and write Ruby strings into them.
|
|
9
|
+
module CharArrayFields
|
|
10
|
+
# The char array +field+ as a String, up to the first NUL byte.
|
|
11
|
+
def string(field)
|
|
12
|
+
self[field].to_ptr.get_bytes(0, self[field].size).unpack1("Z*")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Writes +value+ into the char array +field+, NUL-padded. A value may
|
|
16
|
+
# fill the array exactly; a longer one raises ArgumentError.
|
|
17
|
+
def set_string(field, value)
|
|
18
|
+
bytes = value.to_s.b
|
|
19
|
+
size = self[field].size
|
|
20
|
+
raise ArgumentError, "#{field} is at most #{size} bytes, got #{bytes.bytesize}" if bytes.bytesize > size
|
|
21
|
+
|
|
22
|
+
self[field].to_ptr.put_bytes(0, bytes.ljust(size, "\0"))
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# SF_INFO: passed to sf_open, which fills it in when reading and reads
|
|
28
|
+
# it when writing.
|
|
29
|
+
class Info < FFI::Struct
|
|
30
|
+
layout :frames, :int64,
|
|
31
|
+
:samplerate, :int,
|
|
32
|
+
:channels, :int,
|
|
33
|
+
:format, :int,
|
|
34
|
+
:sections, :int,
|
|
35
|
+
:seekable, :int
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# SF_FORMAT_INFO: describes a format (SFC_GET_FORMAT_INFO and friends).
|
|
39
|
+
class FormatInfo < FFI::Struct
|
|
40
|
+
layout :format, :int,
|
|
41
|
+
:name, :string,
|
|
42
|
+
:extension, :string
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# SF_DITHER_INFO (reserved for future use by libsndfile).
|
|
46
|
+
class DitherInfo < FFI::Struct
|
|
47
|
+
layout :type, :int,
|
|
48
|
+
:level, :double,
|
|
49
|
+
:name, :string
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# SF_EMBED_FILE_INFO: where a sound file sits inside a larger file.
|
|
53
|
+
class EmbedFileInfo < FFI::Struct
|
|
54
|
+
layout :offset, :int64,
|
|
55
|
+
:length, :int64
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# SF_CUE_POINT: one cue marker.
|
|
59
|
+
class CuePoint < FFI::Struct
|
|
60
|
+
include CharArrayFields
|
|
61
|
+
|
|
62
|
+
layout :indx, :int32,
|
|
63
|
+
:position, :uint32,
|
|
64
|
+
:fcc_chunk, :int32,
|
|
65
|
+
:chunk_start, :int32,
|
|
66
|
+
:block_start, :int32,
|
|
67
|
+
:sample_offset, :uint32,
|
|
68
|
+
:name, [:char, 256]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# SF_CUES: up to 100 cue markers (SFC_GET_CUE / SFC_SET_CUE).
|
|
72
|
+
class Cues < FFI::Struct
|
|
73
|
+
MAX_CUE_POINTS = 100
|
|
74
|
+
|
|
75
|
+
layout :cue_count, :uint32,
|
|
76
|
+
:cue_points, [CuePoint, MAX_CUE_POINTS]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# SF_INSTRUMENT: sampler instrument data (SFC_GET/SET_INSTRUMENT).
|
|
80
|
+
class Instrument < FFI::Struct
|
|
81
|
+
# One entry of SF_INSTRUMENT's loops array.
|
|
82
|
+
class Loop < FFI::Struct
|
|
83
|
+
layout :mode, :int,
|
|
84
|
+
:start, :uint32,
|
|
85
|
+
:end, :uint32,
|
|
86
|
+
:count, :uint32
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
MAX_LOOPS = 16
|
|
90
|
+
|
|
91
|
+
layout :gain, :int,
|
|
92
|
+
:basenote, :char,
|
|
93
|
+
:detune, :char,
|
|
94
|
+
:velocity_lo, :char,
|
|
95
|
+
:velocity_hi, :char,
|
|
96
|
+
:key_lo, :char,
|
|
97
|
+
:key_hi, :char,
|
|
98
|
+
:loop_count, :int,
|
|
99
|
+
:loops, [Loop, MAX_LOOPS]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# SF_LOOP_INFO: loop/tempo information (SFC_GET_LOOP_INFO).
|
|
103
|
+
class LoopInfo < FFI::Struct
|
|
104
|
+
layout :time_sig_num, :short,
|
|
105
|
+
:time_sig_den, :short,
|
|
106
|
+
:loop_mode, :int,
|
|
107
|
+
:num_beats, :int,
|
|
108
|
+
:bpm, :float,
|
|
109
|
+
:root_key, :int,
|
|
110
|
+
:future, [:int, 6]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# SF_BROADCAST_INFO: the Broadcast WAV "bext" chunk, with 256 bytes of
|
|
114
|
+
# coding history (SFC_GET/SET_BROADCAST_INFO).
|
|
115
|
+
class BroadcastInfo < FFI::Struct
|
|
116
|
+
include CharArrayFields
|
|
117
|
+
|
|
118
|
+
layout :description, [:char, 256],
|
|
119
|
+
:originator, [:char, 32],
|
|
120
|
+
:originator_reference, [:char, 32],
|
|
121
|
+
:origination_date, [:char, 10],
|
|
122
|
+
:origination_time, [:char, 8],
|
|
123
|
+
:time_reference_low, :uint32,
|
|
124
|
+
:time_reference_high, :uint32,
|
|
125
|
+
:version, :short,
|
|
126
|
+
:umid, [:char, 64],
|
|
127
|
+
:loudness_value, :int16,
|
|
128
|
+
:loudness_range, :int16,
|
|
129
|
+
:max_true_peak_level, :int16,
|
|
130
|
+
:max_momentary_loudness, :int16,
|
|
131
|
+
:max_shortterm_loudness, :int16,
|
|
132
|
+
:reserved, [:char, 180],
|
|
133
|
+
:coding_history_size, :uint32,
|
|
134
|
+
:coding_history, [:char, 256]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# SF_CART_TIMER: one post timer of SF_CART_INFO.
|
|
138
|
+
class CartTimer < FFI::Struct
|
|
139
|
+
include CharArrayFields
|
|
140
|
+
|
|
141
|
+
layout :usage, [:char, 4],
|
|
142
|
+
:value, :int32
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# SF_CART_INFO: the AES46 "cart" chunk, with 256 bytes of tag text
|
|
146
|
+
# (SFC_GET/SET_CART_INFO).
|
|
147
|
+
class CartInfo < FFI::Struct
|
|
148
|
+
include CharArrayFields
|
|
149
|
+
|
|
150
|
+
layout :version, [:char, 4],
|
|
151
|
+
:title, [:char, 64],
|
|
152
|
+
:artist, [:char, 64],
|
|
153
|
+
:cut_id, [:char, 64],
|
|
154
|
+
:client_id, [:char, 64],
|
|
155
|
+
:category, [:char, 64],
|
|
156
|
+
:classification, [:char, 64],
|
|
157
|
+
:out_cue, [:char, 64],
|
|
158
|
+
:start_date, [:char, 10],
|
|
159
|
+
:start_time, [:char, 8],
|
|
160
|
+
:end_date, [:char, 10],
|
|
161
|
+
:end_time, [:char, 8],
|
|
162
|
+
:producer_app_id, [:char, 64],
|
|
163
|
+
:producer_app_version, [:char, 64],
|
|
164
|
+
:user_def, [:char, 64],
|
|
165
|
+
:level_reference, :int32,
|
|
166
|
+
:post_timers, [CartTimer, 8],
|
|
167
|
+
:reserved, [:char, 276],
|
|
168
|
+
:url, [:char, 1024],
|
|
169
|
+
:tag_text_size, :uint32,
|
|
170
|
+
:tag_text, [:char, 256]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# SF_VIRTUAL_IO: the callbacks sf_open_virtual uses instead of a file.
|
|
174
|
+
# Every callback gets the user_data pointer passed to sf_open_virtual.
|
|
175
|
+
class VirtualIO < FFI::Struct
|
|
176
|
+
layout :get_filelen, callback([:pointer], :int64),
|
|
177
|
+
:seek, callback(%i[int64 int pointer], :int64),
|
|
178
|
+
:read, callback(%i[pointer int64 pointer], :int64),
|
|
179
|
+
:write, callback(%i[pointer int64 pointer], :int64),
|
|
180
|
+
:tell, callback([:pointer], :int64)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# SF_CHUNK_INFO: a chunk of a sound file (sf_set_chunk and the chunk
|
|
184
|
+
# iterator functions).
|
|
185
|
+
class ChunkInfo < FFI::Struct
|
|
186
|
+
include CharArrayFields
|
|
187
|
+
|
|
188
|
+
layout :id, [:char, 64],
|
|
189
|
+
:id_size, :uint,
|
|
190
|
+
:datalen, :uint,
|
|
191
|
+
:data, :pointer
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sndfile/constants"
|
|
4
|
+
require "sndfile/library_version"
|
|
5
|
+
|
|
6
|
+
module SndFile
|
|
7
|
+
# The libsndfile bindings: an FFI::Library with every function in
|
|
8
|
+
# sndfile.h, plus the structs and enums those functions use.
|
|
9
|
+
module Native
|
|
10
|
+
extend FFI::Library
|
|
11
|
+
|
|
12
|
+
ffi_lib ["sndfile", "libsndfile.so.1", "libsndfile.1.dylib", "libsndfile-1"]
|
|
13
|
+
|
|
14
|
+
typedef :pointer, :sndfile
|
|
15
|
+
typedef :int64, :sf_count_t
|
|
16
|
+
|
|
17
|
+
# Attached first, so the version check below can run before the other
|
|
18
|
+
# functions: attaching a function that an older libsndfile lacks would
|
|
19
|
+
# fail with an unclear FFI::NotFoundError.
|
|
20
|
+
attach_function :sf_version_string, [], :string
|
|
21
|
+
LibraryVersion.ensure_supported!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require "sndfile/native/enums"
|
|
26
|
+
require "sndfile/native/structs"
|
|
27
|
+
require "sndfile/native/handle"
|
|
28
|
+
require "sndfile/native/functions"
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SndFile
|
|
4
|
+
# A sound file opened for reading, writing or both.
|
|
5
|
+
#
|
|
6
|
+
# Samples are flat Arrays with the channels interleaved: left, right, left,
|
|
7
|
+
# right, ... for stereo. They are :float (the default, -1.0..1.0), :double,
|
|
8
|
+
# :short or :int; libsndfile converts them from and to the file's encoding.
|
|
9
|
+
class SoundFile
|
|
10
|
+
include Commands
|
|
11
|
+
|
|
12
|
+
SAMPLE_TYPES = { short: :short, int: :int, float: :float, double: :double }.freeze
|
|
13
|
+
|
|
14
|
+
# Frames per read in #each_buffer and #read without a frame count.
|
|
15
|
+
DEFAULT_BUFFER_FRAMES = 4096
|
|
16
|
+
|
|
17
|
+
# The path, or the IO object for files opened through VirtualIO.
|
|
18
|
+
attr_reader :target
|
|
19
|
+
|
|
20
|
+
# :read, :write or :rdwr.
|
|
21
|
+
attr_reader :mode
|
|
22
|
+
|
|
23
|
+
# Opens a sound file. Without a block, returns the SoundFile. With a
|
|
24
|
+
# block, yields it, closes it afterwards (also when the block raises)
|
|
25
|
+
# and returns the block's value.
|
|
26
|
+
def self.open(target, mode = :read, **, &)
|
|
27
|
+
file = new(target, mode, **)
|
|
28
|
+
return file unless block_given?
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
yield file
|
|
32
|
+
ensure
|
|
33
|
+
file.close
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Opens +target+, a path or an IO-like object (see VirtualIO), in +mode+
|
|
38
|
+
# :read, :write or :rdwr.
|
|
39
|
+
#
|
|
40
|
+
# Writing needs +samplerate+, +channels+ and a +format+: anything
|
|
41
|
+
# Format.coerce accepts, with an optional +subtype+ and +endian+. Without
|
|
42
|
+
# a format, it follows the path's extension. Reading RAW files needs the
|
|
43
|
+
# same options. +strings+ are set before any audio is written.
|
|
44
|
+
def initialize(target, mode = :read, format: nil, subtype: nil, endian: nil, samplerate: nil, channels: nil,
|
|
45
|
+
strings: nil)
|
|
46
|
+
@target = target
|
|
47
|
+
@mode = Native.enum_type(:sf_mode).find(Native.enum_type(:sf_mode).to_native(mode, nil))
|
|
48
|
+
info = native_info(format_for(format, subtype, endian), samplerate, channels)
|
|
49
|
+
@handle = open_handle(target, info)
|
|
50
|
+
self.strings.update(strings) if strings
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The file's current Info (SFC_GET_CURRENT_SF_INFO), which includes the
|
|
54
|
+
# frames written so far.
|
|
55
|
+
def info
|
|
56
|
+
struct = Native::Info.new
|
|
57
|
+
command(SFC_GET_CURRENT_SF_INFO, struct)
|
|
58
|
+
Info.from_native(struct)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def frames = info.frames
|
|
62
|
+
def samplerate = info.samplerate
|
|
63
|
+
def channels = info.channels
|
|
64
|
+
def format = info.format
|
|
65
|
+
def sections = info.sections
|
|
66
|
+
def seekable? = info.seekable?
|
|
67
|
+
def duration = info.duration
|
|
68
|
+
|
|
69
|
+
# The string metadata (title, artist, ...); see Strings.
|
|
70
|
+
def strings
|
|
71
|
+
@strings ||= Strings.new(self)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The current byte rate (bytes of audio per second), or nil if unknown.
|
|
75
|
+
def byterate
|
|
76
|
+
rate = Native.sf_current_byterate(handle)
|
|
77
|
+
rate unless rate.negative?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reads up to +frames+ frames and returns their samples as a flat,
|
|
81
|
+
# interleaved Array (empty at the end of the file). Without +frames+,
|
|
82
|
+
# reads everything up to the end of the file.
|
|
83
|
+
def read(frames = nil, type: :float)
|
|
84
|
+
return read_all(type) if frames.nil?
|
|
85
|
+
|
|
86
|
+
ffi_type = ffi_type_for(type)
|
|
87
|
+
channel_count = channels
|
|
88
|
+
buffer = FFI::MemoryPointer.new(ffi_type, frames * channel_count)
|
|
89
|
+
read_frames = Native.public_send(:"sf_readf_#{type}", handle, buffer, frames)
|
|
90
|
+
check_io!
|
|
91
|
+
buffer.public_send(:"get_array_of_#{ffi_type}", 0, read_frames * channel_count)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Yields the samples of each buffer of up to +frames+ frames until the
|
|
95
|
+
# end of the file. Returns an Enumerator without a block.
|
|
96
|
+
def each_buffer(frames = DEFAULT_BUFFER_FRAMES, type: :float)
|
|
97
|
+
return enum_for(:each_buffer, frames, type: type) unless block_given?
|
|
98
|
+
|
|
99
|
+
loop do
|
|
100
|
+
samples = read(frames, type: type)
|
|
101
|
+
break if samples.empty?
|
|
102
|
+
|
|
103
|
+
yield samples
|
|
104
|
+
end
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reads up to +bytes+ bytes of the encoded audio data (sf_read_raw).
|
|
109
|
+
def read_raw(bytes)
|
|
110
|
+
buffer = FFI::MemoryPointer.new(:char, bytes)
|
|
111
|
+
count = Native.sf_read_raw(handle, buffer, bytes)
|
|
112
|
+
check_io!
|
|
113
|
+
buffer.get_bytes(0, count)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Writes +samples+, a flat interleaved Array whose size is a multiple of
|
|
117
|
+
# the channel count. Returns the number of frames written.
|
|
118
|
+
def write(samples, type: :float)
|
|
119
|
+
ffi_type = ffi_type_for(type)
|
|
120
|
+
frames = frame_count(samples)
|
|
121
|
+
return 0 if frames.zero?
|
|
122
|
+
|
|
123
|
+
buffer = FFI::MemoryPointer.new(ffi_type, samples.size)
|
|
124
|
+
buffer.public_send(:"put_array_of_#{ffi_type}", 0, samples)
|
|
125
|
+
written = Native.public_send(:"sf_writef_#{type}", handle, buffer, frames)
|
|
126
|
+
check_io!(failed: written < frames)
|
|
127
|
+
written
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Writes +samples+ as floats and returns the file, for chaining.
|
|
131
|
+
def <<(samples)
|
|
132
|
+
write(samples)
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Writes +data+, already encoded in the file's format (sf_write_raw).
|
|
137
|
+
# Returns the number of bytes written.
|
|
138
|
+
def write_raw(data)
|
|
139
|
+
bytes = data.to_s.b
|
|
140
|
+
buffer = FFI::MemoryPointer.new(:char, bytes.bytesize)
|
|
141
|
+
buffer.put_bytes(0, bytes)
|
|
142
|
+
written = Native.sf_write_raw(handle, buffer, bytes.bytesize)
|
|
143
|
+
check_io!(failed: written < bytes.bytesize)
|
|
144
|
+
written
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Forces written data to disk (sf_write_sync).
|
|
148
|
+
def sync
|
|
149
|
+
Native.sf_write_sync(handle)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Moves to frame +frames+, relative to +whence+ (:set, :cur or :end).
|
|
154
|
+
# Returns the new position in frames.
|
|
155
|
+
def seek(frames, whence = :set)
|
|
156
|
+
position = Native.sf_seek(handle, frames, whence)
|
|
157
|
+
check_io!(failed: position.negative?)
|
|
158
|
+
position
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The current position in frames.
|
|
162
|
+
def tell
|
|
163
|
+
seek(0, :cur)
|
|
164
|
+
end
|
|
165
|
+
alias pos tell
|
|
166
|
+
|
|
167
|
+
def rewind
|
|
168
|
+
seek(0, :set)
|
|
169
|
+
self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Closes the file, writing any buffered data and the final header. Does
|
|
173
|
+
# nothing when it is already closed.
|
|
174
|
+
def close
|
|
175
|
+
return if closed?
|
|
176
|
+
|
|
177
|
+
result = Native.sf_close(handle)
|
|
178
|
+
@closed = true
|
|
179
|
+
@virtual_io&.raise_error!
|
|
180
|
+
Error.check_error(result)
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def closed?
|
|
185
|
+
@closed == true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# libsndfile's description of the last error on this file.
|
|
189
|
+
def error_message
|
|
190
|
+
Native.sf_strerror(handle)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The Native::Handle. Raises ClosedFileError once the file is closed.
|
|
194
|
+
def handle
|
|
195
|
+
raise ClosedFileError, "#{inspect} is closed" if closed?
|
|
196
|
+
|
|
197
|
+
@handle
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def inspect
|
|
201
|
+
state = closed? ? "closed" : "#{format} #{channels}ch #{samplerate}Hz"
|
|
202
|
+
"#<#{self.class.name} #{target_name} (#{mode}, #{state})>"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def format_for(format, subtype, endian)
|
|
208
|
+
return Format.coerce(format, subtype, endian) if format
|
|
209
|
+
return unless mode == :write
|
|
210
|
+
|
|
211
|
+
return Format.for_path(target) if path?(target)
|
|
212
|
+
|
|
213
|
+
raise ArgumentError, "A format is needed to write to #{target.class}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def native_info(format, samplerate, channels)
|
|
217
|
+
check_write_options(format, samplerate, channels) if mode == :write
|
|
218
|
+
Native::Info.new.tap do |info|
|
|
219
|
+
info[:format] = format.to_i if format
|
|
220
|
+
info[:samplerate] = samplerate.to_i if samplerate
|
|
221
|
+
info[:channels] = channels.to_i if channels
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def check_write_options(format, samplerate, channels)
|
|
226
|
+
raise ArgumentError, "Cannot tell the format of #{target_name}; pass format:" unless format
|
|
227
|
+
|
|
228
|
+
missing = { samplerate: samplerate, channels: channels }.select { |_, value| value.nil? }.keys
|
|
229
|
+
raise ArgumentError, "Writing needs #{missing.join(" and ")}" if missing.any?
|
|
230
|
+
return if format.valid?(samplerate: samplerate, channels: channels)
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "libsndfile cannot write #{format} with #{samplerate} Hz and #{channels} channel(s)"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def open_handle(target, info)
|
|
236
|
+
if path?(target)
|
|
237
|
+
Native.sf_open(target.to_s, mode, info)
|
|
238
|
+
elsif target.respond_to?(:read) || target.respond_to?(:write)
|
|
239
|
+
@virtual_io = VirtualIO.new(target)
|
|
240
|
+
open_virtual(info)
|
|
241
|
+
else
|
|
242
|
+
raise ArgumentError, "Expected a path or an IO, got #{target.class}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def open_virtual(info)
|
|
247
|
+
Native.sf_open_virtual(@virtual_io.struct, mode, info, nil)
|
|
248
|
+
rescue SndFileError
|
|
249
|
+
@virtual_io.raise_error!
|
|
250
|
+
raise
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def read_all(type)
|
|
254
|
+
each_buffer(DEFAULT_BUFFER_FRAMES, type: type).reduce([], :concat)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ffi_type_for(type)
|
|
258
|
+
SAMPLE_TYPES.fetch(type) do
|
|
259
|
+
raise ArgumentError, "Unknown sample type #{type.inspect}; expected one of #{SAMPLE_TYPES.keys.join(", ")}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def frame_count(samples)
|
|
264
|
+
channel_count = channels
|
|
265
|
+
unless (samples.size % channel_count).zero?
|
|
266
|
+
raise ArgumentError, "#{samples.size} samples is not a whole number of #{channel_count}-channel frames"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
samples.size / channel_count
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Raises an IO callback's exception, or libsndfile's error when +failed+
|
|
273
|
+
# (a short write or failed seek) or when libsndfile recorded one.
|
|
274
|
+
def check_io!(failed: false)
|
|
275
|
+
@virtual_io&.raise_error!
|
|
276
|
+
Error.check_sndfile(handle)
|
|
277
|
+
raise SndFileError, error_message if failed
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Strings and Pathnames are paths; anything else is opened as an IO.
|
|
281
|
+
def path?(target)
|
|
282
|
+
target.is_a?(String) || (defined?(::Pathname) && target.is_a?(::Pathname))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def target_name
|
|
286
|
+
path?(target) ? target.to_s.inspect : target.class.name
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SndFile
|
|
4
|
+
# The string metadata of a sound file, such as its title and artist
|
|
5
|
+
# (sf_get_string / sf_set_string). libsndfile stores them as the format's
|
|
6
|
+
# own tags: Vorbis comments in Ogg and FLAC, the INFO chunk in WAV. Most
|
|
7
|
+
# formats need them set before any audio is written.
|
|
8
|
+
class Strings
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# The string types, in libsndfile's order (SF_STR_*).
|
|
12
|
+
NAMES = Native.enum_type(:sf_str).symbols.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(sound_file)
|
|
15
|
+
@sound_file = sound_file
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The string +name+ (one of NAMES), or nil when the file has none. It is
|
|
19
|
+
# returned as UTF-8, or as binary when it isn't valid UTF-8.
|
|
20
|
+
def [](name)
|
|
21
|
+
value = Native.sf_get_string(@sound_file.handle, name_for(name))
|
|
22
|
+
return if value.nil?
|
|
23
|
+
|
|
24
|
+
utf8 = value.dup.force_encoding(Encoding::UTF_8)
|
|
25
|
+
utf8.valid_encoding? ? utf8 : value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sets the string +name+ (one of NAMES). Raises a SndFileError when the
|
|
29
|
+
# file format doesn't support it or audio data was already written.
|
|
30
|
+
def []=(name, value)
|
|
31
|
+
handle = @sound_file.handle
|
|
32
|
+
Error.check_error(Native.sf_set_string(handle, name_for(name), value.to_s.encode(Encoding::UTF_8)), handle)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
NAMES.each do |name|
|
|
36
|
+
define_method(name) { self[name] }
|
|
37
|
+
define_method(:"#{name}=") { |value| self[name] = value }
|
|
38
|
+
end
|
|
39
|
+
alias track_number tracknumber
|
|
40
|
+
alias track_number= tracknumber=
|
|
41
|
+
|
|
42
|
+
# Sets every string in +hash+, e.g. update(title: "Song", artist: "Me").
|
|
43
|
+
def update(hash)
|
|
44
|
+
hash.each { |name, value| self[name] = value }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Yields each string that is set, as name and value.
|
|
49
|
+
def each(&)
|
|
50
|
+
return enum_for(:each) unless block_given?
|
|
51
|
+
|
|
52
|
+
to_h.each(&)
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
NAMES.filter_map { |name| (value = self[name]) && [name, value] }.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
"#<#{self.class.name} #{to_h.inspect}>"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def name_for(name)
|
|
67
|
+
symbol = name.to_s.downcase.to_sym
|
|
68
|
+
symbol = :tracknumber if symbol == :track_number
|
|
69
|
+
return symbol if NAMES.include?(symbol)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "Unknown string #{name.inspect}; expected one of #{NAMES.join(", ")}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SndFile
|
|
4
|
+
# Lets libsndfile read and write an IO-like object, such as a StringIO,
|
|
5
|
+
# through sf_open_virtual. The object needs #read or #write, and #seek and
|
|
6
|
+
# #pos for formats that seek, which most do when writing.
|
|
7
|
+
#
|
|
8
|
+
# Exceptions from the IO must not unwind through libsndfile, so they are
|
|
9
|
+
# kept in #error, and SoundFile raises them again.
|
|
10
|
+
class VirtualIO
|
|
11
|
+
# For each SF_VIRTUAL_IO callback: the method that implements it and the
|
|
12
|
+
# value it returns to libsndfile when the IO raises.
|
|
13
|
+
CALLBACKS = {
|
|
14
|
+
get_filelen: [:io_length, -1],
|
|
15
|
+
seek: [:io_seek, -1],
|
|
16
|
+
read: [:io_read, 0],
|
|
17
|
+
write: [:io_write, 0],
|
|
18
|
+
tell: [:io_tell, -1]
|
|
19
|
+
}.freeze
|
|
20
|
+
private_constant :CALLBACKS
|
|
21
|
+
|
|
22
|
+
attr_reader :io, :struct, :error
|
|
23
|
+
|
|
24
|
+
def initialize(io)
|
|
25
|
+
@io = io
|
|
26
|
+
@struct = Native::VirtualIO.new
|
|
27
|
+
# The struct only holds C function pointers; keep the procs referenced
|
|
28
|
+
# so they aren't garbage collected while libsndfile may call them.
|
|
29
|
+
@callbacks = callbacks
|
|
30
|
+
@callbacks.each { |name, callback| @struct[name] = callback }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raises (and clears) the exception an IO callback caught, if any.
|
|
34
|
+
def raise_error!
|
|
35
|
+
return unless @error
|
|
36
|
+
|
|
37
|
+
error = @error
|
|
38
|
+
@error = nil
|
|
39
|
+
raise error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The procs ignore the user_data pointer libsndfile passes last.
|
|
45
|
+
def callbacks
|
|
46
|
+
CALLBACKS.transform_values do |(method_name, failure_value)|
|
|
47
|
+
->(*args) { guard(failure_value) { send(method_name, *args[0...-1]) } }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def guard(failure_value)
|
|
52
|
+
yield
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
@error ||= e
|
|
55
|
+
failure_value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def io_length
|
|
59
|
+
return io.size if io.respond_to?(:size)
|
|
60
|
+
return io.stat.size if io.respond_to?(:stat)
|
|
61
|
+
|
|
62
|
+
-1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def io_seek(offset, whence)
|
|
66
|
+
io.seek(offset, whence)
|
|
67
|
+
io.pos
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def io_read(ptr, count)
|
|
71
|
+
data = io.read(count)
|
|
72
|
+
return 0 if data.nil? || data.empty?
|
|
73
|
+
|
|
74
|
+
ptr.put_bytes(0, data)
|
|
75
|
+
data.bytesize
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def io_write(ptr, count)
|
|
79
|
+
io.write(ptr.get_bytes(0, count))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def io_tell
|
|
83
|
+
io.pos
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|