muze 0.1.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +5 -0
- data/Rakefile +3 -0
- data/ext/muze/muze_ext.c +129 -12
- data/lib/muze/beat/beat_track.rb +93 -11
- data/lib/muze/core/audio.rb +129 -0
- data/lib/muze/core/cache.rb +38 -0
- data/lib/muze/core/dct.rb +24 -21
- data/lib/muze/core/frames.rb +31 -0
- data/lib/muze/core/matrix.rb +23 -0
- data/lib/muze/core/resample.rb +111 -19
- data/lib/muze/core/stft.rb +312 -52
- data/lib/muze/core/windows.rb +113 -17
- data/lib/muze/display/specshow.rb +307 -41
- data/lib/muze/effects/harmonic_percussive.rb +83 -18
- data/lib/muze/effects/streaming.rb +101 -0
- data/lib/muze/effects/time_stretch.rb +353 -36
- data/lib/muze/feature/aggregation.rb +49 -0
- data/lib/muze/feature/chroma.rb +43 -15
- data/lib/muze/feature/context.rb +81 -0
- data/lib/muze/feature/mfcc.rb +78 -38
- data/lib/muze/feature/spectral.rb +258 -39
- data/lib/muze/filters/chroma_filter.rb +21 -2
- data/lib/muze/filters/mel.rb +47 -1
- data/lib/muze/io/audio_loader/ffmpeg_backend.rb +179 -15
- data/lib/muze/io/audio_loader/wavify_backend.rb +118 -11
- data/lib/muze/io/audio_loader.rb +178 -48
- data/lib/muze/io/audio_writer.rb +48 -0
- data/lib/muze/native.rb +91 -8
- data/lib/muze/onset/onset_detect.rb +114 -23
- data/lib/muze/version.rb +1 -1
- data/lib/muze.rb +237 -60
- metadata +11 -21
- data/benchmarks/baseline.json +0 -24
- data/benchmarks/native_vs_ruby.rb +0 -23
- data/benchmarks/quality_metrics.rb +0 -265
- data/benchmarks/quality_thresholds.md +0 -28
- data/benchmarks/support/fixture_library.rb +0 -107
data/lib/muze/core/stft.rb
CHANGED
|
@@ -5,6 +5,8 @@ module Muze
|
|
|
5
5
|
# Short-time Fourier transform and related utilities.
|
|
6
6
|
module STFT
|
|
7
7
|
EPSILON = 1.0e-12
|
|
8
|
+
MAX_N_FFT = 262_144
|
|
9
|
+
FREQUENCY_CACHE = Muze::Core::BoundedCache.new(max_size: 64)
|
|
8
10
|
module_function
|
|
9
11
|
|
|
10
12
|
# @param y [Numo::SFloat, Array<Float>] waveform signal
|
|
@@ -14,31 +16,35 @@ module Muze
|
|
|
14
16
|
# @param window [Symbol]
|
|
15
17
|
# @param center [Boolean]
|
|
16
18
|
# @param pad_mode [Symbol]
|
|
19
|
+
# @param pad_end [Boolean]
|
|
17
20
|
# @return [Numo::DComplex] shape: [1 + n_fft/2, frames]
|
|
18
|
-
def stft(y, n_fft: 2048, hop_length: 512, win_length: nil, window: :hann, center: true, pad_mode: :reflect)
|
|
19
|
-
_ = pad_mode
|
|
21
|
+
def stft(y, n_fft: 2048, hop_length: 512, win_length: nil, window: :hann, center: true, pad_mode: :reflect, pad_end: false, periodic: false)
|
|
20
22
|
win_length ||= n_fft
|
|
21
23
|
validate_stft_params!(n_fft:, hop_length:, win_length:)
|
|
24
|
+
validate_pad_mode!(pad_mode)
|
|
22
25
|
|
|
23
|
-
signal =
|
|
24
|
-
signal =
|
|
26
|
+
signal = signal_to_a(y)
|
|
27
|
+
signal = pad_signal(signal, n_fft / 2, pad_mode) if center
|
|
25
28
|
signal = signal.empty? ? [0.0] : signal
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
window_values = Muze::Core::Windows.resolve(window, win_length).to_a
|
|
30
|
+
window_values = Muze::Core::Windows.resolve(window, win_length, periodic:).to_a
|
|
29
31
|
window_offset = (n_fft - win_length) / 2
|
|
32
|
+
frame_count = analysis_frame_count(signal.length, n_fft:, hop_length:, pad_end:)
|
|
30
33
|
|
|
31
34
|
frequency_bins = (n_fft / 2) + 1
|
|
32
|
-
stft_matrix = Numo::DComplex.zeros(frequency_bins,
|
|
35
|
+
stft_matrix = Numo::DComplex.zeros(frequency_bins, frame_count)
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
frame_count.times do |frame_index|
|
|
38
|
+
frame_start = frame_index * hop_length
|
|
35
39
|
windowed = Array.new(n_fft, 0.0)
|
|
36
40
|
win_length.times do |index|
|
|
37
41
|
frame_index_in_window = index + window_offset
|
|
38
|
-
|
|
42
|
+
source_index = frame_start + frame_index_in_window
|
|
43
|
+
sample = source_index < signal.length ? signal[source_index] : 0.0
|
|
44
|
+
windowed[frame_index_in_window] = sample * window_values[index]
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
spectrum =
|
|
47
|
+
spectrum = fft_real(windowed)
|
|
42
48
|
frequency_bins.times { |bin| stft_matrix[bin, frame_index] = spectrum[bin] }
|
|
43
49
|
end
|
|
44
50
|
|
|
@@ -52,23 +58,23 @@ module Muze
|
|
|
52
58
|
# @param center [Boolean]
|
|
53
59
|
# @param length [Integer, nil]
|
|
54
60
|
# @return [Numo::SFloat]
|
|
55
|
-
def istft(stft_matrix, hop_length: 512, win_length: nil, window: :hann, center: true, length: nil)
|
|
61
|
+
def istft(stft_matrix, hop_length: 512, win_length: nil, window: :hann, center: true, length: nil, dtype: Numo::SFloat, periodic: false)
|
|
62
|
+
stft_matrix = cast_complex_matrix(stft_matrix, "stft_matrix")
|
|
56
63
|
frequency_bins, frame_count = stft_matrix.shape
|
|
57
64
|
n_fft = (frequency_bins - 1) * 2
|
|
58
65
|
win_length ||= n_fft
|
|
59
66
|
validate_stft_params!(n_fft:, hop_length:, win_length:)
|
|
67
|
+
raise Muze::ParameterError, "length must be non-negative" if length && (!length.is_a?(Integer) || length.negative?)
|
|
60
68
|
|
|
61
69
|
signal_length = n_fft + (hop_length * [frame_count - 1, 0].max)
|
|
62
70
|
output = Array.new(signal_length, 0.0)
|
|
63
71
|
window_sums = Array.new(signal_length, 0.0)
|
|
64
|
-
window_values = Muze::Core::Windows.resolve(window, win_length).to_a
|
|
72
|
+
window_values = Muze::Core::Windows.resolve(window, win_length, periodic:).to_a
|
|
65
73
|
window_offset = (n_fft - win_length) / 2
|
|
66
74
|
|
|
67
75
|
frame_count.times do |frame_index|
|
|
68
76
|
half_spectrum = Array.new(frequency_bins) { |bin| stft_matrix[bin, frame_index] }
|
|
69
|
-
|
|
70
|
-
full_spectrum = half_spectrum + mirrored
|
|
71
|
-
time_domain = ifft_complex(full_spectrum).map(&:real)
|
|
77
|
+
time_domain = ifft_real(half_spectrum).to_a
|
|
72
78
|
|
|
73
79
|
win_length.times do |index|
|
|
74
80
|
output_index = (frame_index * hop_length) + index + window_offset
|
|
@@ -92,24 +98,72 @@ module Muze
|
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
output = adjust_length(output, length) if length
|
|
95
|
-
|
|
101
|
+
dtype_class(dtype).cast(output)
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
# @param stft_matrix [Numo::DComplex]
|
|
105
|
+
# @param eps [Float]
|
|
106
|
+
# @param dtype [Class, Symbol]
|
|
99
107
|
# @return [Array<Numo::SFloat, Numo::DComplex>]
|
|
100
|
-
def magphase(stft_matrix)
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
def magphase(stft_matrix, eps: EPSILON, dtype: Numo::SFloat)
|
|
109
|
+
unless eps.respond_to?(:positive?) && eps.respond_to?(:finite?) && eps.positive? && eps.finite?
|
|
110
|
+
raise Muze::ParameterError, "eps must be positive"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
stft_matrix = cast_complex_matrix(stft_matrix, "stft_matrix")
|
|
114
|
+
magnitude = stft_matrix.abs.cast_to(dtype_class(dtype))
|
|
115
|
+
phase = stft_matrix / (magnitude + eps)
|
|
103
116
|
[magnitude, phase]
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
# @param chunks [Enumerable<Array<Float>, Numo::NArray>]
|
|
120
|
+
# @return [Array<Numo::DComplex>]
|
|
121
|
+
def stft_stream(chunks, n_fft: 2048, hop_length: 512, win_length: nil, window: :hann, center: false, pad_mode: :reflect, periodic: false, flush: true)
|
|
122
|
+
return chunks.map { |chunk| stft(chunk, n_fft:, hop_length:, win_length:, window:, center:, pad_mode:, pad_end: true, periodic:) } if center
|
|
123
|
+
|
|
124
|
+
win_length ||= n_fft
|
|
125
|
+
validate_stft_params!(n_fft:, hop_length:, win_length:)
|
|
126
|
+
raise Muze::ParameterError, "flush must be true or false" unless [true, false].include?(flush)
|
|
127
|
+
|
|
128
|
+
buffer = []
|
|
129
|
+
results = []
|
|
130
|
+
sentinel = Object.new
|
|
131
|
+
enumerator = chunks.each
|
|
132
|
+
chunk = next_stream_chunk(enumerator, sentinel)
|
|
133
|
+
|
|
134
|
+
until chunk.equal?(sentinel)
|
|
135
|
+
following = next_stream_chunk(enumerator, sentinel)
|
|
136
|
+
final = following.equal?(sentinel)
|
|
137
|
+
buffer.concat(signal_to_a(chunk))
|
|
138
|
+
frame_count = stream_frame_count(buffer.length, n_fft:, hop_length:, final: final && flush)
|
|
139
|
+
results << if frame_count.zero?
|
|
140
|
+
empty_stft_matrix(n_fft)
|
|
141
|
+
else
|
|
142
|
+
matrix = stft(buffer, n_fft:, hop_length:, win_length:, window:, center: false, pad_end: final && flush, periodic:)
|
|
143
|
+
emitted = matrix.shape[1]
|
|
144
|
+
consumed = final && flush ? buffer.length : emitted * hop_length
|
|
145
|
+
buffer = buffer[consumed..] || []
|
|
146
|
+
matrix
|
|
147
|
+
end
|
|
148
|
+
chunk = following
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
results
|
|
152
|
+
end
|
|
153
|
+
|
|
106
154
|
# @param s [Numo::NArray]
|
|
107
155
|
# @param ref [Float, Symbol, Proc]
|
|
108
156
|
# @param amin [Float]
|
|
109
157
|
# @param top_db [Float, nil]
|
|
158
|
+
# @param abs [Boolean]
|
|
110
159
|
# @return [Numo::SFloat]
|
|
111
|
-
def amplitude_to_db(s, ref: 1.0, amin: 1.0e-5, top_db: 80.0)
|
|
112
|
-
magnitude = s.is_a?(Numo::DComplex)
|
|
160
|
+
def amplitude_to_db(s, ref: 1.0, amin: 1.0e-5, top_db: 80.0, abs: false)
|
|
161
|
+
magnitude = if s.is_a?(Numo::DComplex)
|
|
162
|
+
s.abs.cast_to(Numo::SFloat)
|
|
163
|
+
else
|
|
164
|
+
values = Numo::SFloat.cast(s)
|
|
165
|
+
abs ? values.abs : values
|
|
166
|
+
end
|
|
113
167
|
log_scale(magnitude, ref:, amin:, top_db:, multiplier: 20.0)
|
|
114
168
|
end
|
|
115
169
|
|
|
@@ -127,6 +181,7 @@ module Muze
|
|
|
127
181
|
# @param ref [Float]
|
|
128
182
|
# @return [Numo::SFloat]
|
|
129
183
|
def db_to_amplitude(s_db, ref: 1.0)
|
|
184
|
+
validate_db_inverse_args!(s_db, ref)
|
|
130
185
|
Numo::SFloat.cast(ref.to_f * Numo::NMath.exp((Numo::SFloat.cast(s_db) / 20.0) * Math.log(10.0)))
|
|
131
186
|
end
|
|
132
187
|
|
|
@@ -134,9 +189,55 @@ module Muze
|
|
|
134
189
|
# @param ref [Float]
|
|
135
190
|
# @return [Numo::SFloat]
|
|
136
191
|
def db_to_power(s_db, ref: 1.0)
|
|
192
|
+
validate_db_inverse_args!(s_db, ref)
|
|
137
193
|
Numo::SFloat.cast(ref.to_f * Numo::NMath.exp((Numo::SFloat.cast(s_db) / 10.0) * Math.log(10.0)))
|
|
138
194
|
end
|
|
139
195
|
|
|
196
|
+
# @param sr [Integer]
|
|
197
|
+
# @param n_fft [Integer]
|
|
198
|
+
# @return [Numo::SFloat]
|
|
199
|
+
def fft_frequencies(sr:, n_fft:)
|
|
200
|
+
raise Muze::ParameterError, "sr must be a positive integer" unless sr.is_a?(Integer) && sr.positive?
|
|
201
|
+
raise Muze::ParameterError, "n_fft must be a positive integer" unless n_fft.is_a?(Integer) && n_fft.positive?
|
|
202
|
+
|
|
203
|
+
key = [sr, n_fft]
|
|
204
|
+
FREQUENCY_CACHE.fetch(key) { Numo::SFloat.cast(Array.new((n_fft / 2) + 1) { |index| index * sr.to_f / n_fft }) }.dup
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @param frames [Integer, Array<Integer>, Numo::NArray]
|
|
208
|
+
# @param sr [Integer]
|
|
209
|
+
# @param hop_length [Integer]
|
|
210
|
+
# @return [Float, Numo::SFloat]
|
|
211
|
+
def frames_to_time(frames, sr:, hop_length:)
|
|
212
|
+
samples_to_time(frames_to_samples(frames, hop_length:), sr:)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @param times [Float, Array<Float>, Numo::NArray]
|
|
216
|
+
# @param sr [Integer]
|
|
217
|
+
# @param hop_length [Integer]
|
|
218
|
+
# @return [Integer, Numo::Int64]
|
|
219
|
+
def time_to_frames(times, sr:, hop_length:)
|
|
220
|
+
samples_to_frames(time_to_samples(times, sr:), hop_length:)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @param frames [Integer, Array<Integer>, Numo::NArray]
|
|
224
|
+
# @param hop_length [Integer]
|
|
225
|
+
# @return [Integer, Numo::Int64]
|
|
226
|
+
def frames_to_samples(frames, hop_length:)
|
|
227
|
+
raise Muze::ParameterError, "hop_length must be positive" unless hop_length.positive?
|
|
228
|
+
|
|
229
|
+
map_scalar_or_array(frames) { |frame| (frame.to_i * hop_length).to_i }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @param samples [Integer, Array<Integer>, Numo::NArray]
|
|
233
|
+
# @param hop_length [Integer]
|
|
234
|
+
# @return [Integer, Numo::Int64]
|
|
235
|
+
def samples_to_frames(samples, hop_length:)
|
|
236
|
+
raise Muze::ParameterError, "hop_length must be positive" unless hop_length.positive?
|
|
237
|
+
|
|
238
|
+
map_scalar_or_array(samples) { |sample| (sample.to_i / hop_length.to_f).floor }
|
|
239
|
+
end
|
|
240
|
+
|
|
140
241
|
def adjust_length(signal, length)
|
|
141
242
|
return signal[0, length] if signal.length >= length
|
|
142
243
|
|
|
@@ -144,7 +245,40 @@ module Muze
|
|
|
144
245
|
end
|
|
145
246
|
private_class_method :adjust_length
|
|
146
247
|
|
|
248
|
+
def stream_frame_count(length, n_fft:, hop_length:, final:)
|
|
249
|
+
return final && length.positive? ? 1 : 0 if length <= n_fft
|
|
250
|
+
return (((length - n_fft).to_f / hop_length).ceil + 1) if final
|
|
251
|
+
|
|
252
|
+
((length - n_fft) / hop_length) + 1
|
|
253
|
+
end
|
|
254
|
+
private_class_method :stream_frame_count
|
|
255
|
+
|
|
256
|
+
def empty_stft_matrix(n_fft)
|
|
257
|
+
Numo::DComplex.zeros((n_fft / 2) + 1, 0)
|
|
258
|
+
end
|
|
259
|
+
private_class_method :empty_stft_matrix
|
|
260
|
+
|
|
261
|
+
def next_stream_chunk(enumerator, sentinel)
|
|
262
|
+
enumerator.next
|
|
263
|
+
rescue StopIteration
|
|
264
|
+
sentinel
|
|
265
|
+
end
|
|
266
|
+
private_class_method :next_stream_chunk
|
|
267
|
+
|
|
268
|
+
def analysis_frame_count(length, n_fft:, hop_length:, pad_end:)
|
|
269
|
+
return 1 if length <= n_fft
|
|
270
|
+
return (((length - n_fft).to_f / hop_length).ceil + 1) if pad_end
|
|
271
|
+
|
|
272
|
+
((length - n_fft) / hop_length) + 1
|
|
273
|
+
end
|
|
274
|
+
private_class_method :analysis_frame_count
|
|
275
|
+
|
|
147
276
|
def log_scale(values, ref:, amin:, top_db:, multiplier:)
|
|
277
|
+
raise Muze::ParameterError, "amin must be positive" unless amin.positive?
|
|
278
|
+
raise Muze::ParameterError, "top_db must be non-negative" if top_db && top_db.negative?
|
|
279
|
+
validate_finite_values!(values, "input")
|
|
280
|
+
raise Muze::ParameterError, "input values must be non-negative" if contains_negative?(values)
|
|
281
|
+
|
|
148
282
|
clipped = values.clip(amin, Float::INFINITY)
|
|
149
283
|
reference = reference_value(ref, clipped, amin)
|
|
150
284
|
base = multiplier * Math.log10(reference)
|
|
@@ -161,71 +295,197 @@ module Muze
|
|
|
161
295
|
value = case ref
|
|
162
296
|
when :max then values.max
|
|
163
297
|
when Proc then ref.call(values)
|
|
298
|
+
when Numeric then ref.to_f
|
|
164
299
|
else
|
|
165
|
-
ref
|
|
300
|
+
raise Muze::ParameterError, "ref must be numeric, :max, or a Proc"
|
|
166
301
|
end
|
|
167
302
|
|
|
303
|
+
raise Muze::ParameterError, "ref must be finite" unless value.finite?
|
|
304
|
+
|
|
168
305
|
[value, amin].max
|
|
169
306
|
end
|
|
170
307
|
private_class_method :reference_value
|
|
171
308
|
|
|
172
309
|
def validate_stft_params!(n_fft:, hop_length:, win_length:)
|
|
310
|
+
raise Muze::ParameterError, "n_fft must be an integer" unless n_fft.is_a?(Integer)
|
|
311
|
+
raise Muze::ParameterError, "hop_length must be an integer" unless hop_length.is_a?(Integer)
|
|
312
|
+
raise Muze::ParameterError, "win_length must be an integer" unless win_length.is_a?(Integer)
|
|
173
313
|
raise Muze::ParameterError, "n_fft must be positive" if n_fft <= 0
|
|
174
|
-
raise Muze::ParameterError, "n_fft must be
|
|
314
|
+
raise Muze::ParameterError, "n_fft must be <= #{MAX_N_FFT}" if n_fft > MAX_N_FFT
|
|
315
|
+
raise Muze::ParameterError, "n_fft must be even" unless n_fft.even?
|
|
175
316
|
raise Muze::ParameterError, "hop_length must be positive" if hop_length <= 0
|
|
176
317
|
raise Muze::ParameterError, "hop_length must be <= n_fft" if hop_length > n_fft
|
|
177
318
|
raise Muze::ParameterError, "win_length must be between 1 and n_fft" unless win_length.between?(1, n_fft)
|
|
178
319
|
end
|
|
179
320
|
private_class_method :validate_stft_params!
|
|
180
321
|
|
|
181
|
-
def
|
|
182
|
-
|
|
322
|
+
def validate_pad_mode!(pad_mode)
|
|
323
|
+
return if %i[reflect constant edge].include?(pad_mode)
|
|
324
|
+
|
|
325
|
+
raise Muze::ParameterError, "pad_mode must be :reflect, :constant, or :edge"
|
|
326
|
+
end
|
|
327
|
+
private_class_method :validate_pad_mode!
|
|
328
|
+
|
|
329
|
+
def signal_to_a(y)
|
|
330
|
+
raise Muze::ParameterError, "y must not be nil" if y.nil?
|
|
331
|
+
|
|
332
|
+
signal = y.is_a?(Numo::NArray) ? y.to_a : Array(y)
|
|
333
|
+
if signal.first.is_a?(Array)
|
|
334
|
+
raise Muze::ParameterError, "stft expects mono audio; process channels separately or downmix first"
|
|
335
|
+
end
|
|
336
|
+
validate_finite_array!(signal, "y")
|
|
337
|
+
signal
|
|
338
|
+
end
|
|
339
|
+
private_class_method :signal_to_a
|
|
340
|
+
|
|
341
|
+
def cast_complex_matrix(value, label)
|
|
342
|
+
matrix = Numo::DComplex.cast(value)
|
|
343
|
+
raise Muze::ParameterError, "#{label} must be two-dimensional" unless matrix.ndim == 2
|
|
344
|
+
raise Muze::ParameterError, "#{label} must have at least two frequency bins" if matrix.shape[0] < 2
|
|
345
|
+
validate_finite_array!(matrix.real.to_a.flatten, label)
|
|
346
|
+
validate_finite_array!(matrix.imag.to_a.flatten, label)
|
|
347
|
+
matrix
|
|
348
|
+
rescue NoMethodError, TypeError, ArgumentError => e
|
|
349
|
+
raise Muze::ParameterError, "#{label} must be a complex STFT matrix: #{e.message}"
|
|
350
|
+
end
|
|
351
|
+
private_class_method :cast_complex_matrix
|
|
352
|
+
|
|
353
|
+
def validate_db_inverse_args!(values, ref)
|
|
354
|
+
unless ref.respond_to?(:positive?) && ref.respond_to?(:finite?) && ref.positive? && ref.finite?
|
|
355
|
+
raise Muze::ParameterError, "ref must be positive and finite"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
validate_finite_values!(Numo::SFloat.cast(values), "input")
|
|
359
|
+
rescue NoMethodError, TypeError, ArgumentError => e
|
|
360
|
+
raise Muze::ParameterError, "input must contain numeric dB values: #{e.message}"
|
|
361
|
+
end
|
|
362
|
+
private_class_method :validate_db_inverse_args!
|
|
363
|
+
|
|
364
|
+
def pad_signal(signal, pad, mode)
|
|
365
|
+
return signal if pad <= 0
|
|
366
|
+
|
|
367
|
+
case mode
|
|
368
|
+
when :constant
|
|
369
|
+
Array.new(pad, 0.0) + signal + Array.new(pad, 0.0)
|
|
370
|
+
when :edge
|
|
371
|
+
edge_pad(signal, pad)
|
|
372
|
+
when :reflect
|
|
373
|
+
reflect_pad(signal, pad)
|
|
374
|
+
end
|
|
183
375
|
end
|
|
184
|
-
private_class_method :
|
|
376
|
+
private_class_method :pad_signal
|
|
185
377
|
|
|
186
|
-
def
|
|
187
|
-
|
|
378
|
+
def edge_pad(signal, pad)
|
|
379
|
+
return Array.new(pad * 2, 0.0) if signal.empty?
|
|
380
|
+
|
|
381
|
+
Array.new(pad, signal.first) + signal + Array.new(pad, signal.last)
|
|
188
382
|
end
|
|
189
|
-
private_class_method :
|
|
383
|
+
private_class_method :edge_pad
|
|
190
384
|
|
|
191
385
|
def reflect_pad(signal, pad)
|
|
192
|
-
return signal
|
|
386
|
+
return Array.new(pad, 0.0) + signal + Array.new(pad, 0.0) if signal.length <= 1
|
|
193
387
|
|
|
194
|
-
front = signal
|
|
195
|
-
back = signal
|
|
388
|
+
front = reflected_values(signal, pad, from_start: true)
|
|
389
|
+
back = reflected_values(signal, pad, from_start: false)
|
|
196
390
|
front + signal + back
|
|
197
391
|
end
|
|
198
392
|
private_class_method :reflect_pad
|
|
199
393
|
|
|
200
|
-
def
|
|
201
|
-
|
|
202
|
-
|
|
394
|
+
def reflected_values(signal, pad, from_start:)
|
|
395
|
+
period = (signal.length - 1) * 2
|
|
396
|
+
Array.new(pad) do |index|
|
|
397
|
+
offset = pad - index
|
|
398
|
+
reflected_index = reflect_index(from_start ? -offset : signal.length - 1 + offset, period)
|
|
399
|
+
signal[reflected_index]
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
private_class_method :reflected_values
|
|
403
|
+
|
|
404
|
+
def reflect_index(index, period)
|
|
405
|
+
value = index % period
|
|
406
|
+
value = period - value if value >= (period / 2) + 1
|
|
407
|
+
value
|
|
408
|
+
end
|
|
409
|
+
private_class_method :reflect_index
|
|
410
|
+
|
|
411
|
+
def fft_real(values)
|
|
412
|
+
Numo::Pocketfft.rfft(Numo::DFloat.cast(values))
|
|
413
|
+
rescue ArgumentError, TypeError => e
|
|
414
|
+
raise Muze::ParameterError, "FFT failed: #{e.message}"
|
|
415
|
+
end
|
|
416
|
+
private_class_method :fft_real
|
|
417
|
+
|
|
418
|
+
def ifft_real(values)
|
|
419
|
+
Numo::Pocketfft.irfft(Numo::DComplex.cast(values))
|
|
420
|
+
rescue ArgumentError, TypeError => e
|
|
421
|
+
raise Muze::ParameterError, "inverse FFT failed: #{e.message}"
|
|
422
|
+
end
|
|
423
|
+
private_class_method :ifft_real
|
|
424
|
+
|
|
425
|
+
def contains_negative?(values)
|
|
426
|
+
flatten_values(values).any?(&:negative?)
|
|
427
|
+
end
|
|
428
|
+
private_class_method :contains_negative?
|
|
429
|
+
|
|
430
|
+
def validate_finite_values!(values, label)
|
|
431
|
+
validate_finite_array!(flatten_values(values), label)
|
|
432
|
+
end
|
|
433
|
+
private_class_method :validate_finite_values!
|
|
434
|
+
|
|
435
|
+
def validate_finite_array!(values, label)
|
|
436
|
+
return if values.all? { |value| value.respond_to?(:finite?) && value.finite? }
|
|
437
|
+
|
|
438
|
+
raise Muze::ParameterError, "#{label} must contain only finite numeric values"
|
|
439
|
+
end
|
|
440
|
+
private_class_method :validate_finite_array!
|
|
441
|
+
|
|
442
|
+
def flatten_values(values)
|
|
443
|
+
values.is_a?(Numo::NArray) ? values.to_a.flatten : Array(values).flatten
|
|
444
|
+
end
|
|
445
|
+
private_class_method :flatten_values
|
|
203
446
|
|
|
204
|
-
|
|
447
|
+
def time_to_samples(times, sr:)
|
|
448
|
+
raise Muze::ParameterError, "sr must be positive" unless sr.positive?
|
|
449
|
+
|
|
450
|
+
map_scalar_or_array(times) { |time| (time.to_f * sr).round }
|
|
451
|
+
end
|
|
205
452
|
|
|
206
|
-
|
|
207
|
-
|
|
453
|
+
def samples_to_time(samples, sr:)
|
|
454
|
+
raise Muze::ParameterError, "sr must be positive" unless sr.positive?
|
|
208
455
|
|
|
209
|
-
|
|
210
|
-
|
|
456
|
+
map_scalar_or_array(samples) { |sample| sample.to_f / sr }
|
|
457
|
+
end
|
|
211
458
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
459
|
+
def map_scalar_or_array(value)
|
|
460
|
+
if value.is_a?(Numo::NArray)
|
|
461
|
+
Numo::SFloat.cast(value.to_a.flatten.map { |item| yield(item) }).reshape(*value.shape)
|
|
462
|
+
elsif value.is_a?(Array)
|
|
463
|
+
Numo::SFloat.cast(value.flatten.map { |item| yield(item) }).reshape(*array_shape(value))
|
|
464
|
+
else
|
|
465
|
+
yield(value)
|
|
216
466
|
end
|
|
467
|
+
end
|
|
468
|
+
private_class_method :map_scalar_or_array
|
|
469
|
+
|
|
470
|
+
def array_shape(value)
|
|
471
|
+
return [value.length] unless value.first.is_a?(Array)
|
|
217
472
|
|
|
218
|
-
|
|
473
|
+
[value.length, value.first.length]
|
|
219
474
|
end
|
|
220
|
-
private_class_method :
|
|
475
|
+
private_class_method :array_shape
|
|
221
476
|
|
|
222
|
-
def
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
477
|
+
def dtype_class(dtype)
|
|
478
|
+
case dtype
|
|
479
|
+
when :sfloat, :float32 then Numo::SFloat
|
|
480
|
+
when :dfloat, :float64 then Numo::DFloat
|
|
481
|
+
else
|
|
482
|
+
return Numo::SFloat if dtype == Numo::SFloat
|
|
483
|
+
return Numo::DFloat if dtype == Numo::DFloat
|
|
484
|
+
|
|
485
|
+
raise Muze::ParameterError, "dtype must be :sfloat, :float32, :dfloat, :float64, Numo::SFloat, or Numo::DFloat"
|
|
486
|
+
end
|
|
227
487
|
end
|
|
228
|
-
private_class_method :
|
|
488
|
+
private_class_method :dtype_class
|
|
229
489
|
end
|
|
230
490
|
end
|
|
231
491
|
end
|
data/lib/muze/core/windows.rb
CHANGED
|
@@ -4,38 +4,93 @@ module Muze
|
|
|
4
4
|
module Core
|
|
5
5
|
# Window function generators for short-time analysis.
|
|
6
6
|
module Windows
|
|
7
|
+
CACHE = Muze::Core::BoundedCache.new(max_size: 64)
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
10
|
# @param n [Integer]
|
|
11
|
+
# @param periodic [Boolean]
|
|
10
12
|
# @return [Numo::SFloat]
|
|
11
|
-
def hann(n)
|
|
13
|
+
def hann(n, periodic: false)
|
|
12
14
|
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
13
15
|
return Numo::SFloat[1.0] if n == 1
|
|
14
16
|
|
|
15
|
-
build_window(n) { |k, denom| 0.5 * (1.0 - Math.cos((2.0 * Math::PI * k) / denom)) }
|
|
17
|
+
build_window(n, periodic:) { |k, denom| 0.5 * (1.0 - Math.cos((2.0 * Math::PI * k) / denom)) }
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# @param n [Integer]
|
|
21
|
+
# @param periodic [Boolean]
|
|
19
22
|
# @return [Numo::SFloat]
|
|
20
|
-
def hamming(n)
|
|
23
|
+
def hamming(n, periodic: false)
|
|
21
24
|
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
22
25
|
return Numo::SFloat[1.0] if n == 1
|
|
23
26
|
|
|
24
|
-
build_window(n) { |k, denom| 0.54 - (0.46 * Math.cos((2.0 * Math::PI * k) / denom)) }
|
|
27
|
+
build_window(n, periodic:) { |k, denom| 0.54 - (0.46 * Math.cos((2.0 * Math::PI * k) / denom)) }
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# @param n [Integer]
|
|
31
|
+
# @param periodic [Boolean]
|
|
28
32
|
# @return [Numo::SFloat]
|
|
29
|
-
def blackman(n)
|
|
33
|
+
def blackman(n, periodic: false)
|
|
30
34
|
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
31
35
|
return Numo::SFloat[1.0] if n == 1
|
|
32
36
|
|
|
33
|
-
build_window(n) do |k, denom|
|
|
37
|
+
build_window(n, periodic:) do |k, denom|
|
|
34
38
|
phase = (2.0 * Math::PI * k) / denom
|
|
35
39
|
0.42 - (0.5 * Math.cos(phase)) + (0.08 * Math.cos(2.0 * phase))
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
|
|
43
|
+
# @param n [Integer]
|
|
44
|
+
# @param periodic [Boolean]
|
|
45
|
+
# @return [Numo::SFloat]
|
|
46
|
+
def blackman_harris(n, periodic: false)
|
|
47
|
+
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
48
|
+
return Numo::SFloat[1.0] if n == 1
|
|
49
|
+
|
|
50
|
+
build_window(n, periodic:) do |k, denom|
|
|
51
|
+
phase = (2.0 * Math::PI * k) / denom
|
|
52
|
+
0.35875 - (0.48829 * Math.cos(phase)) + (0.14128 * Math.cos(2.0 * phase)) - (0.01168 * Math.cos(3.0 * phase))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param n [Integer]
|
|
57
|
+
# @param beta [Float]
|
|
58
|
+
# @param periodic [Boolean]
|
|
59
|
+
# @return [Numo::SFloat]
|
|
60
|
+
def kaiser(n, beta: 14.0, periodic: false)
|
|
61
|
+
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
62
|
+
return Numo::SFloat[1.0] if n == 1
|
|
63
|
+
|
|
64
|
+
denominator = bessel_i0(beta)
|
|
65
|
+
build_window(n, periodic:) do |k, denom|
|
|
66
|
+
ratio = ((2.0 * k) / denom) - 1.0
|
|
67
|
+
bessel_i0(beta * Math.sqrt([1.0 - (ratio * ratio), 0.0].max)) / denominator
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param n [Integer]
|
|
72
|
+
# @param alpha [Float]
|
|
73
|
+
# @param periodic [Boolean]
|
|
74
|
+
# @return [Numo::SFloat]
|
|
75
|
+
def tukey(n, alpha: 0.5, periodic: false)
|
|
76
|
+
raise Muze::ParameterError, "window length must be positive" if n <= 0
|
|
77
|
+
raise Muze::ParameterError, "alpha must be between 0 and 1" unless alpha.between?(0.0, 1.0)
|
|
78
|
+
return ones(n) if alpha.zero?
|
|
79
|
+
return hann(n, periodic:) if alpha == 1.0
|
|
80
|
+
return Numo::SFloat[1.0] if n == 1
|
|
81
|
+
|
|
82
|
+
build_window(n, periodic:) do |k, denom|
|
|
83
|
+
position = k.to_f / denom
|
|
84
|
+
if position < alpha / 2.0
|
|
85
|
+
0.5 * (1.0 + Math.cos(Math::PI * ((2.0 * position / alpha) - 1.0)))
|
|
86
|
+
elsif position <= 1.0 - (alpha / 2.0)
|
|
87
|
+
1.0
|
|
88
|
+
else
|
|
89
|
+
0.5 * (1.0 + Math.cos(Math::PI * ((2.0 * position / alpha) - (2.0 / alpha) + 1.0)))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
39
94
|
# @param n [Integer]
|
|
40
95
|
# @return [Numo::SFloat]
|
|
41
96
|
def ones(n)
|
|
@@ -44,26 +99,67 @@ module Muze
|
|
|
44
99
|
Numo::SFloat.ones(n)
|
|
45
100
|
end
|
|
46
101
|
|
|
47
|
-
# @param name [Symbol]
|
|
102
|
+
# @param name [Symbol, Array, Numo::NArray, Proc]
|
|
48
103
|
# @param n [Integer]
|
|
104
|
+
# @param periodic [Boolean]
|
|
49
105
|
# @return [Numo::SFloat]
|
|
50
|
-
def resolve(name, n)
|
|
51
|
-
case name
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
106
|
+
def resolve(name, n, periodic: false)
|
|
107
|
+
resolved = case name
|
|
108
|
+
when Numo::NArray then Numo::SFloat.cast(name)
|
|
109
|
+
when Array then Numo::SFloat.cast(name)
|
|
110
|
+
when Proc then Numo::SFloat.cast(name.call(n))
|
|
111
|
+
when Symbol then cached_symbol_window(name, n, periodic:).dup
|
|
112
|
+
else
|
|
113
|
+
raise Muze::ParameterError, "Unsupported window: #{name.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise Muze::ParameterError, "window must have length #{n}" unless resolved.size == n
|
|
117
|
+
|
|
118
|
+
resolved
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cached_symbol_window(name, n, periodic:)
|
|
122
|
+
key = [name, n, periodic]
|
|
123
|
+
CACHE.fetch(key) do
|
|
124
|
+
case name
|
|
125
|
+
when :hann then hann(n, periodic:)
|
|
126
|
+
when :hamming then hamming(n, periodic:)
|
|
127
|
+
when :blackman then blackman(n, periodic:)
|
|
128
|
+
when :blackman_harris, :blackmanharris then blackman_harris(n, periodic:)
|
|
129
|
+
when :kaiser then kaiser(n, periodic:)
|
|
130
|
+
when :tukey then tukey(n, periodic:)
|
|
131
|
+
when :ones, :boxcar, :rect then ones(n)
|
|
132
|
+
else
|
|
133
|
+
raise Muze::ParameterError, "Unsupported window: #{name}"
|
|
134
|
+
end
|
|
58
135
|
end
|
|
59
136
|
end
|
|
137
|
+
private_class_method :cached_symbol_window
|
|
60
138
|
|
|
61
|
-
def build_window(length)
|
|
62
|
-
denominator = length - 1
|
|
139
|
+
def build_window(length, periodic:)
|
|
140
|
+
denominator = periodic ? length : length - 1
|
|
63
141
|
values = Array.new(length) { |k| yield(k, denominator).to_f }
|
|
64
142
|
Numo::SFloat.cast(values)
|
|
65
143
|
end
|
|
66
144
|
private_class_method :build_window
|
|
145
|
+
|
|
146
|
+
# Approximation of modified Bessel function I0.
|
|
147
|
+
def bessel_i0(value)
|
|
148
|
+
sum = 1.0
|
|
149
|
+
term = 1.0
|
|
150
|
+
k = 1
|
|
151
|
+
|
|
152
|
+
loop do
|
|
153
|
+
term *= ((value / 2.0)**2) / (k * k)
|
|
154
|
+
sum += term
|
|
155
|
+
break if term < 1.0e-12
|
|
156
|
+
|
|
157
|
+
k += 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
sum
|
|
161
|
+
end
|
|
162
|
+
private_class_method :bessel_i0
|
|
67
163
|
end
|
|
68
164
|
end
|
|
69
165
|
end
|