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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +5 -0
  4. data/Rakefile +3 -0
  5. data/ext/muze/muze_ext.c +129 -12
  6. data/lib/muze/beat/beat_track.rb +93 -11
  7. data/lib/muze/core/audio.rb +129 -0
  8. data/lib/muze/core/cache.rb +38 -0
  9. data/lib/muze/core/dct.rb +24 -21
  10. data/lib/muze/core/frames.rb +31 -0
  11. data/lib/muze/core/matrix.rb +23 -0
  12. data/lib/muze/core/resample.rb +111 -19
  13. data/lib/muze/core/stft.rb +312 -52
  14. data/lib/muze/core/windows.rb +113 -17
  15. data/lib/muze/display/specshow.rb +307 -41
  16. data/lib/muze/effects/harmonic_percussive.rb +83 -18
  17. data/lib/muze/effects/streaming.rb +101 -0
  18. data/lib/muze/effects/time_stretch.rb +353 -36
  19. data/lib/muze/feature/aggregation.rb +49 -0
  20. data/lib/muze/feature/chroma.rb +43 -15
  21. data/lib/muze/feature/context.rb +81 -0
  22. data/lib/muze/feature/mfcc.rb +78 -38
  23. data/lib/muze/feature/spectral.rb +258 -39
  24. data/lib/muze/filters/chroma_filter.rb +21 -2
  25. data/lib/muze/filters/mel.rb +47 -1
  26. data/lib/muze/io/audio_loader/ffmpeg_backend.rb +179 -15
  27. data/lib/muze/io/audio_loader/wavify_backend.rb +118 -11
  28. data/lib/muze/io/audio_loader.rb +178 -48
  29. data/lib/muze/io/audio_writer.rb +48 -0
  30. data/lib/muze/native.rb +91 -8
  31. data/lib/muze/onset/onset_detect.rb +114 -23
  32. data/lib/muze/version.rb +1 -1
  33. data/lib/muze.rb +237 -60
  34. metadata +11 -21
  35. data/benchmarks/baseline.json +0 -24
  36. data/benchmarks/native_vs_ruby.rb +0 -23
  37. data/benchmarks/quality_metrics.rb +0 -265
  38. data/benchmarks/quality_thresholds.md +0 -28
  39. data/benchmarks/support/fixture_library.rb +0 -107
@@ -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 = y.is_a?(Numo::NArray) ? y.to_a : Array(y)
24
- signal = reflect_pad(signal, n_fft / 2) if center
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
- frames = frame_signal(signal, n_fft, hop_length)
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, frames.length)
35
+ stft_matrix = Numo::DComplex.zeros(frequency_bins, frame_count)
33
36
 
34
- frames.each_with_index do |frame, frame_index|
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
- windowed[frame_index_in_window] = frame[frame_index_in_window] * window_values[index]
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 = fft_complex(windowed.map { |value| Complex(value, 0.0) })
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
- mirrored = half_spectrum[1...-1].reverse.map(&:conj)
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
- Numo::SFloat.cast(output)
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
- magnitude = stft_matrix.abs.cast_to(Numo::SFloat)
102
- phase = stft_matrix / (magnitude + EPSILON)
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) ? s.abs.cast_to(Numo::SFloat) : Numo::SFloat.cast(s)
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.to_f
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 a power of two" unless power_of_two?(n_fft)
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 power_of_two?(value)
182
- (value & (value - 1)).zero?
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 :power_of_two?
376
+ private_class_method :pad_signal
185
377
 
186
- def frame_signal(signal, n_fft, hop_length)
187
- Muze::Native.frame_slices(signal, n_fft, hop_length)
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 :frame_signal
383
+ private_class_method :edge_pad
190
384
 
191
385
  def reflect_pad(signal, pad)
192
- return signal if pad <= 0 || signal.length <= 1
386
+ return Array.new(pad, 0.0) + signal + Array.new(pad, 0.0) if signal.length <= 1
193
387
 
194
- front = signal[1, pad].to_a.reverse
195
- back = signal[-(pad + 1), pad].to_a.reverse
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 fft_complex(values)
201
- length = values.length
202
- return values if length <= 1
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
- raise Muze::ParameterError, "FFT length must be a power of two" unless power_of_two?(length)
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
- even = fft_complex(values.values_at(*0.step(length - 1, 2)))
207
- odd = fft_complex(values.values_at(*1.step(length - 1, 2)))
453
+ def samples_to_time(samples, sr:)
454
+ raise Muze::ParameterError, "sr must be positive" unless sr.positive?
208
455
 
209
- output = Array.new(length)
210
- half = length / 2
456
+ map_scalar_or_array(samples) { |sample| sample.to_f / sr }
457
+ end
211
458
 
212
- half.times do |k|
213
- twiddle = Complex.polar(1.0, -2.0 * Math::PI * k / length) * odd[k]
214
- output[k] = even[k] + twiddle
215
- output[k + half] = even[k] - twiddle
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
- output
473
+ [value.length, value.first.length]
219
474
  end
220
- private_class_method :fft_complex
475
+ private_class_method :array_shape
221
476
 
222
- def ifft_complex(values)
223
- conjugated = values.map(&:conj)
224
- transformed = fft_complex(conjugated)
225
- scale = values.length.to_f
226
- transformed.map { |value| value.conj / scale }
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 :ifft_complex
488
+ private_class_method :dtype_class
229
489
  end
230
490
  end
231
491
  end
@@ -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
- when :hann then hann(n)
53
- when :hamming then hamming(n)
54
- when :blackman then blackman(n)
55
- when :ones, :boxcar, :rect then ones(n)
56
- else
57
- raise Muze::ParameterError, "Unsupported window: #{name}"
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