dsprb 1.0.0 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9551a9c99d503314354a81f6b445eef6c0c29181d55907581856bc1904c2494
4
- data.tar.gz: ebd353edf01ff2a5f1e5694d74f781858a0f7640861e3cd502ce127e6e48b6b5
3
+ metadata.gz: aa50ef5e7332169aacc296720f303cc7f5160a43e9bc56f7fd964efd86b90404
4
+ data.tar.gz: fb948d7001cc0f53e39d09146c71c6b79dc90cb2d1945e5b78b99e8405ffd875
5
5
  SHA512:
6
- metadata.gz: 3a33aeb3039a94a7edbbede3b95514cffca4e49612c1b1d6f241a1034a5ae5a558eead65f286cc58596466578baf18841b4bf703f027d379a01b96c52b7e39e7
7
- data.tar.gz: 673fad7a2420db7c4463edd9b68dd776f8b955b3374a9586e721a993ba648e20df63bd1ac2b67228b1a0cd3a29b223ba1e6ab64b486ed14b7c139ca3eb4b0013
6
+ metadata.gz: c72b16fbd04e9c142957c442da128e24a3493f1924ed79f0edcbac87f23888c8b2b97e57c3821a640248f37aa8a48ed8b906b79d55d12528374754b79ae1a4a7
7
+ data.tar.gz: dc938d6930712e3c7605f767592f99713792481df22d4b788ef0c25559c2ff398cd62face9e070b84274ede4f09d1eb10514ec7e233b8c481be0efdce5b0d871
data/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 — 2026-08-21
4
+
5
+ Performance release. Every value the library produces is unchanged: across a battery of 34
6
+ outputs measured against 1.0.0, twenty are bit-identical and the largest relative deviation
7
+ anywhere is 1.7e-12, in the all-pole envelope.
8
+
9
+ Median of three runs on Ruby 4.0 with YJIT, over a 400 sample frame at 16 kHz and a two
10
+ second waveform:
11
+
12
+ | Operation | 1.0.0 | 2.0.0 | Speedup |
13
+ | --------------------------------- | -------- | -------- | ------- |
14
+ | `Window.hamming(400)` | 113.3 us | 0.4 us | 266x |
15
+ | `Lpc::Envelope` over 512 points | 216.3 us | 67.4 us | 3.21x |
16
+ | `Dct.two(26 -> 13)` | 9.7 us | 3.3 us | 2.98x |
17
+ | `Formants#call` | 411.2 us | 137.9 us | 2.98x |
18
+ | `Mfcc#call` | 176.1 us | 98.9 us | 1.78x |
19
+ | `Spectrum#power`, size 512 | 164.9 us | 95.8 us | 1.72x |
20
+ | `Spectrum#power`, size 1024 | 379.8 us | 238.1 us | 1.60x |
21
+ | `Decimator#call` over 32k samples | 7.1 ms | 5.3 ms | 1.35x |
22
+ | `Yin#call` over 2 s | 63.8 ms | 47.2 ms | 1.35x |
23
+
24
+ ### Breaking
25
+
26
+ - `Window.hamming` and its siblings memoize by length and coefficients and return a **frozen**
27
+ array. Code that modified a window in place must copy it first.
28
+ - `Spectrum` builds a `Fourier::Real` rather than a `Fourier::Radix2`, so `#transform` no longer
29
+ answers to a complex transform's interface. Injecting a `Radix2` explicitly still works.
30
+
31
+ ### Added
32
+
33
+ - `DSP::Fourier::Real`, a transform for real-valued input. It packs the even and odd samples
34
+ into one complex sequence of half the length, so a real frame costs about half of the complex
35
+ transform. Returns the non-redundant bins `0..size/2`.
36
+ - `DSP::Lpc::Envelope`, the all-pole spectral envelope on a fixed grid. Squaring the polynomial
37
+ first turns the sum over coefficients into a sum over their autocorrelation, which is real and
38
+ even, so only cosines remain and the grid precomputes once.
39
+ - `DSP::Dct::Two`, the DCT-II on a fixed pair of lengths with a precomputed basis.
40
+ - `DSP::Prepared`, the shared memoization used by `DSP::Window`, `DSP::Decimator.for`,
41
+ `DSP::Dct::Two.for` and `DSP::Lpc::Envelope.for` to hold one instance per parameter set.
42
+ - `DSP::Check`, the argument validation the stages now share, so a rejected argument reports the
43
+ same way wherever it comes from.
44
+ - `DSP::Decimator.to(waveform, target)`, which selects the factor and filters in one step.
45
+ - `DSP::Yin.new(minimum_rms:)` reports frames quieter than the threshold as unvoiced without
46
+ running the lag search. Silence carries no pitch, and it is most of a short recording. The
47
+ default of zero leaves behavior unchanged.
48
+
49
+ ### Changed
50
+
51
+ - `Formants` holds its envelope grid and reuses prepared decimators and windows instead of
52
+ rebuilding them on every frame, which is where most of its 2.98x comes from.
53
+ - `Mfcc` holds its DCT basis.
54
+ - `Decimator#filter` hoists the bounds test out of the interior of the convolution; only the
55
+ few output samples that reach past the signal pay for it. Output is bit-identical.
56
+ - `Yin` accumulates lags in order and stops at the first dip that satisfies the absolute
57
+ threshold, rather than filling the whole difference function first. Output is bit-identical.
58
+ - The radix-2 butterfly is inlined into the transform loop.
59
+ - `Lpc::Envelope` computes the coefficient autocorrelation through `DSP::Autocorrelation` rather
60
+ than repeating it.
61
+
3
62
  ## 1.0.0 — 2026-08-01
4
63
 
5
64
  Initial public release.
data/README.md CHANGED
@@ -68,6 +68,13 @@ track.each_voiced { |frequency, time, confidence| ... }
68
68
  Unvoiced frames report `0.0` rather than `nil`, so a track is always a dense numeric sequence.
69
69
  Confidence is `1 - d'(τ)`, the depth of the selected minimum.
70
70
 
71
+ Silence carries no pitch and is most of a short recording, so `minimum_rms` skips the lag search on
72
+ frames quieter than it and reports them unvoiced:
73
+
74
+ ```ruby
75
+ DSP.pitch(signal, minimum_rms: 0.01)
76
+ ```
77
+
71
78
  ## Cepstral features
72
79
 
73
80
  `DSP::Mfcc` composes a power spectrum, a mel filterbank and a DCT-II. The filterbank supports
@@ -152,7 +159,7 @@ learner recordings against native templates. Its analysis stage is a pipeline ov
152
159
  **Conditioning.** The recording is high-passed to strip rumble and any DC offset, then the energy
153
160
  envelope and zero crossing rate locate the speech boundaries and the syllable onsets.
154
161
 
155
- **Tone.** Mandarin lexical tone *is* the fundamental frequency contour, so `DSP.pitch` carries the
162
+ **Tone.** Mandarin lexical tone _is_ the fundamental frequency contour, so `DSP.pitch` carries the
156
163
  primary signal rather than a secondary cue. The track is cleaned of octave errors, then
157
164
  `DSP::Curve.resample` brings contours of different duration onto a fixed 16-point grid where the
158
165
  four tones become comparable shapes.
@@ -177,13 +184,20 @@ telemetry — uses the same spectral and predictive machinery.
177
184
  ## Design
178
185
 
179
186
  Every stage is an object that precomputes what it can and then freezes: `DSP::Fourier::Radix2`
180
- caches its twiddle factors, `DSP::MelFilterbank` its triangular bands, `DSP::Decimator` its kernel.
181
- They hold no mutable state, share nothing globally, and are safe to reuse across threads. Build them
182
- once, outside the loop.
187
+ caches its twiddle factors, `DSP::MelFilterbank` its triangular bands, `DSP::Decimator` its kernel,
188
+ `DSP::Lpc::Envelope` its cosine grid, `DSP::Dct::Two` its basis. They hold no mutable state and are
189
+ safe to reuse across threads. Build them once, outside the loop.
190
+
191
+ What depends on nothing but its own sizes is shared rather than rebuilt. Through `DSP::Prepared`,
192
+ `DSP::Window.hamming`, `DSP::Decimator.for`, `DSP::Dct::Two.for` and `DSP::Lpc::Envelope.for` hand
193
+ back one frozen instance per parameter set, so framing a signal does not pay to construct the same
194
+ window on every frame. Argument validation is shared the same way through `DSP::Check`, so a
195
+ rejected argument reports identically wherever it enters.
183
196
 
184
197
  Collaborators are injected rather than assumed: the transform behind a `DSP::Spectrum`, the window
185
- function of an MFCC sequence, the filterbank of a `DSP::Mfcc`. A different FFT backend needs only
186
- `#call(real, imaginary)` and `#size`.
198
+ function of an MFCC sequence, the filterbank of a `DSP::Mfcc`. A spectrum uses `DSP::Fourier::Real`
199
+ by default, which needs only `#power(frame)`; injecting a complex transform such as
200
+ `DSP::Fourier::Radix2` needs `#call(real, imaginary)` and `#size`.
187
201
 
188
202
  `DSP::Waveform`, `DSP::PitchTrack`, `DSP::SpectralMoments`, `DSP::Biquad`, `DSP::Lpc::Model` and
189
203
  `DSP::Formants::Peak` are `Data` value objects: frozen, compared by value, copied with `#with`.
@@ -4,9 +4,9 @@ module DSP
4
4
  module Autocorrelation
5
5
  module_function
6
6
 
7
- # Unbiased-denominator-free autocorrelation r[k] = sum_i x[i] x[i + k], k = 0..order.
7
+ # r[k] = sum_i x[i] x[i + k], k = 0..order, without a denominator.
8
8
  def call(frame, order)
9
- raise ArgumentError, "order must be non-negative, got #{order}" if order.negative?
9
+ Check.non_negative!(order, "order")
10
10
 
11
11
  length = frame.length
12
12
 
data/lib/dsprb/biquad.rb CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  module DSP
4
4
  Biquad = Data.define(:b0, :b1, :b2, :a1, :a2) do
5
- # Q of a Butterworth section: the maximally flat passband alignment.
5
+ # Maximally flat passband alignment.
6
6
  BUTTERWORTH_Q = Math.sqrt(2.0) / 2.0
7
7
 
8
- # Normalized cutoff bounds, keeping the design away from DC and Nyquist.
9
8
  MINIMUM_NORMALIZED = 1e-4
10
9
  MAXIMUM_NORMALIZED = 0.49
11
10
 
@@ -36,7 +35,7 @@ module DSP
36
35
 
37
36
  private_class_method :design
38
37
 
39
- # Direct form II transposed, which keeps the state to two accumulators.
38
+ # Direct form II transposed: two accumulators of state.
40
39
  def call(samples)
41
40
  first = 0.0
42
41
  second = 0.0
@@ -57,7 +56,7 @@ module DSP
57
56
 
58
57
  # Cascading n identical sections yields a filter of order 2n.
59
58
  def apply(samples, sections: 1)
60
- raise ArgumentError, "sections must be positive, got #{sections}" unless sections.positive?
59
+ Check.positive!(sections, "sections")
61
60
 
62
61
  sections.times.reduce(samples) { |signal, _| call(signal) }
63
62
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ module Check
5
+ module_function
6
+
7
+ def positive!(value, name)
8
+ return value if value.positive?
9
+
10
+ raise ArgumentError, "#{name} must be positive, got #{value}"
11
+ end
12
+
13
+ def non_negative!(value, name)
14
+ return value unless value.negative?
15
+
16
+ raise ArgumentError, "#{name} must be non-negative, got #{value}"
17
+ end
18
+
19
+ def at_least!(value, minimum, name)
20
+ return value if value >= minimum
21
+
22
+ raise ArgumentError, "#{name} must be at least #{minimum}, got #{value}"
23
+ end
24
+
25
+ def power_of_two!(value, name)
26
+ return value if value.positive? && (value & (value - 1)).zero?
27
+
28
+ raise ArgumentError, "#{name} must be a positive power of two, got #{value}"
29
+ end
30
+
31
+ def below!(value, limit, name, limit_name)
32
+ return value if value < limit
33
+
34
+ raise ArgumentError, "#{name} must be below #{limit_name}, got #{value} and #{limit}"
35
+ end
36
+
37
+ def present!(collection, name)
38
+ return collection unless collection.empty?
39
+
40
+ raise ArgumentError, "#{name} must not be empty"
41
+ end
42
+ end
43
+ end
data/lib/dsprb/curve.rb CHANGED
@@ -4,10 +4,10 @@ module DSP
4
4
  module Curve
5
5
  module_function
6
6
 
7
- # Linear interpolation onto a uniform grid of the requested length, used to
8
- # bring contours of differing duration onto a common time base.
7
+ # Linear interpolation onto a uniform grid, bringing contours of differing
8
+ # duration onto a common time base.
9
9
  def resample(values, length)
10
- raise ArgumentError, "length must be positive, got #{length}" unless length.positive?
10
+ Check.positive!(length, "length")
11
11
  return Array.new(length, 0.0) if values.empty?
12
12
  return Array.new(length) { values.first.to_f } if values.length == 1 || length == 1
13
13
 
data/lib/dsprb/dct.rb CHANGED
@@ -2,23 +2,43 @@
2
2
 
3
3
  module DSP
4
4
  module Dct
5
- module_function
5
+ # X[k] = sum_i x[i] cos(pi k (i + 1/2) / N), unnormalized DCT-II.
6
+ class Two
7
+ extend Prepared
6
8
 
7
- # Unnormalized DCT-II: X[k] = sum_i x[i] cos(pi k (i + 1/2) / N).
8
- def two(values, count)
9
- length = values.length
10
- raise ArgumentError, "count must be positive, got #{count}" unless count.positive?
11
-
12
- Array.new(count) do |coefficient|
13
- total = 0.0
14
- index = 0
15
- while index < length
16
- total += values[index] * Math.cos(Math::PI * coefficient * (index + 0.5) / length)
17
- index += 1
18
- end
9
+ attr_reader :length, :count
10
+
11
+ def self.for(length, count) = prepared([length, count]) { new(length, count) }
19
12
 
20
- total
13
+ def initialize(length, count)
14
+ @length = Check.positive!(Integer(length), "length")
15
+ @count = Check.positive!(Integer(count), "count")
16
+
17
+ @basis = Array
18
+ .new(@count) do |coefficient|
19
+ Array.new(@length) { |index| Math.cos(Math::PI * coefficient * (index + 0.5) / @length) }.freeze
20
+ end
21
+ .freeze
22
+ freeze
23
+ end
24
+
25
+ def call(values)
26
+ Array.new(@count) do |coefficient|
27
+ row = @basis[coefficient]
28
+ total = 0.0
29
+ index = 0
30
+ while index < @length
31
+ total += values[index] * row[index]
32
+ index += 1
33
+ end
34
+
35
+ total
36
+ end
21
37
  end
22
38
  end
39
+
40
+ module_function
41
+
42
+ def two(values, count) = Two.for(values.length, count).call(values)
23
43
  end
24
44
  end
@@ -5,18 +5,25 @@ module DSP
5
5
  # An odd tap count keeps the FIR linear phase, so decimation adds no group delay skew.
6
6
  DEFAULT_TAPS = 31
7
7
 
8
- # Cutoff as a fraction of the decimated sample rate, leaving a transition margin below Nyquist.
8
+ # Cutoff as a fraction of the decimated rate, leaving a transition margin below Nyquist.
9
9
  CUTOFF_RATIO = 0.45
10
10
 
11
+ extend Prepared
12
+
11
13
  attr_reader :factor, :taps, :kernel
12
14
 
13
15
  def self.factor_for(sample_rate, target) = [(sample_rate / target).floor, 1].max
14
16
 
17
+ def self.for(factor, taps: DEFAULT_TAPS) = prepared([factor, taps]) { new(factor, taps: taps) }
18
+
19
+ def self.to(waveform, target, taps: DEFAULT_TAPS)
20
+ self.for(factor_for(waveform.sample_rate, target), taps: taps).call(waveform)
21
+ end
22
+
15
23
  def initialize(factor, taps: DEFAULT_TAPS)
16
- @factor = Integer(factor)
24
+ @factor = Check.at_least!(Integer(factor), 1, "factor")
17
25
  @taps = Integer(taps)
18
26
 
19
- raise ArgumentError, "factor must be at least one, got #{@factor}" unless @factor >= 1
20
27
  raise ArgumentError, "taps must be a positive odd number, got #{@taps}" unless @taps.positive? && @taps.odd?
21
28
 
22
29
  @middle = (@taps - 1) / 2
@@ -38,12 +45,7 @@ module DSP
38
45
 
39
46
  raw = Array.new(@taps) do |index|
40
47
  offset = index - @middle
41
- sinc = if offset.zero?
42
- 2.0 * cutoff
43
- else
44
- Math.sin(2.0 * Math::PI * cutoff * offset) / (Math::PI * offset)
45
- end
46
-
48
+ sinc = offset.zero? ? 2.0 * cutoff : Math.sin(2.0 * Math::PI * cutoff * offset) / (Math::PI * offset)
47
49
  sinc * taper[index]
48
50
  end
49
51
 
@@ -51,19 +53,36 @@ module DSP
51
53
  raw.map { |value| value / total }
52
54
  end
53
55
 
56
+ # Only the first and last few outputs reach past the signal, so the bound
57
+ # test is hoisted out of the interior and paid for by those alone.
54
58
  def filter(samples)
55
- out = []
59
+ length = samples.length
60
+ kernel = @kernel
61
+ taps = @taps
62
+ out = Array.new((length + @factor - 1) / @factor)
63
+
64
+ cursor = 0
56
65
  position = 0
57
- while position < samples.length
66
+ while position < length
67
+ first = position - @middle
58
68
  total = 0.0
59
- tap = 0
60
- while tap < @taps
61
- index = position + tap - @middle
62
- total += @kernel[tap] * samples[index] if index >= 0 && index < samples.length
63
- tap += 1
69
+ if first >= 0 && first + taps <= length
70
+ tap = 0
71
+ while tap < taps
72
+ total += kernel[tap] * samples[first + tap]
73
+ tap += 1
74
+ end
75
+ else
76
+ tap = 0
77
+ while tap < taps
78
+ index = first + tap
79
+ total += kernel[tap] * samples[index] if index >= 0 && index < length
80
+ tap += 1
81
+ end
64
82
  end
65
83
 
66
- out << total
84
+ out[cursor] = total
85
+ cursor += 1
67
86
  position += @factor
68
87
  end
69
88
 
data/lib/dsprb/energy.rb CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  module DSP
4
4
  module Energy
5
- # Keeps the logarithm of a silent frame finite, placing digital silence near -120 dB.
5
+ # Places digital silence near -120 dB instead of negative infinity.
6
6
  FLOOR = 1e-12
7
7
 
8
8
  module_function
9
9
 
10
10
  def frame_db(frame)
11
- raise ArgumentError, "frame must not be empty" if frame.empty?
11
+ Check.present!(frame, "frame")
12
12
 
13
13
  total = 0.0
14
14
  index = 0
@@ -34,8 +34,8 @@ module DSP
34
34
  end
35
35
 
36
36
  def envelope_db(samples, window:, hop:)
37
- raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
38
- raise ArgumentError, "hop must be positive, got #{hop}" unless hop.positive?
37
+ Check.positive!(window, "window")
38
+ Check.positive!(hop, "hop")
39
39
  return [] if samples.length < window
40
40
 
41
41
  running = 0.0
@@ -7,23 +7,20 @@ module DSP
7
7
  # Conventional ceiling for adult speech; the first four formants fall below it.
8
8
  DEFAULT_MAXIMUM_HZ = 5500.0
9
9
  DEFAULT_COUNT = 4
10
-
11
- # Envelope samples taken over [0, Nyquist].
12
10
  DEFAULT_RESOLUTION = 512
13
11
 
14
- # One resonance can split into twin envelope peaks; peaks closer than this are merged.
12
+ # One resonance can split into twin envelope peaks; closer pairs are merged.
15
13
  DEFAULT_SEPARATION_HZ = 250.0
16
14
 
17
15
  # Peaks pinned against the band edges are artifacts of the truncated envelope.
18
16
  DEFAULT_EDGE_MARGIN_HZ = 150.0
19
17
 
20
- # Below this the frame is too short to fit the predictor reliably.
21
18
  MINIMUM_SAMPLES = 40
22
19
 
23
20
  ENERGY_FLOOR = 1e-10
24
21
  LOG_FLOOR = 1e-18
25
22
 
26
- attr_reader :maximum_frequency, :count, :resolution, :separation, :edge_margin
23
+ attr_reader :maximum_frequency, :count, :resolution, :separation, :edge_margin, :order
27
24
 
28
25
  def initialize(
29
26
  maximum_frequency: DEFAULT_MAXIMUM_HZ,
@@ -38,9 +35,11 @@ module DSP
38
35
  @separation = Float(separation)
39
36
  @edge_margin = Float(edge_margin)
40
37
 
41
- raise ArgumentError, "count must be positive, got #{@count}" unless @count.positive?
42
- raise ArgumentError, "resolution must be at least two, got #{@resolution}" unless @resolution >= 2
38
+ Check.positive!(@count, "count")
39
+ Check.at_least!(@resolution, 2, "resolution")
43
40
 
41
+ @order = (2 * @count) + 2
42
+ @envelope = Lpc::Envelope.for(points: @resolution, order: @order)
44
43
  freeze
45
44
  end
46
45
 
@@ -53,22 +52,19 @@ module DSP
53
52
  model = fit(reduced)
54
53
  return [] if model.nil?
55
54
 
56
- merge(extrema(model.envelope(points: @resolution), reduced.sample_rate)).first(@count)
55
+ merge(extrema(@envelope.call(model.coefficients), reduced.sample_rate)).first(@count)
57
56
  end
58
57
 
59
58
  private
60
59
 
61
- def reduce(waveform)
62
- Decimator.new(Decimator.factor_for(waveform.sample_rate, 2.0 * @maximum_frequency)).call(waveform)
63
- end
60
+ def reduce(waveform) = Decimator.to(waveform, 2.0 * @maximum_frequency)
64
61
 
65
62
  def fit(waveform)
66
- order = (2 * @count) + 2
67
63
  windowed = Window.apply(waveform.samples, Window.hamming(waveform.length))
68
- correlation = Autocorrelation.call(Framing.preemphasis(windowed), order)
64
+ correlation = Autocorrelation.call(Framing.preemphasis(windowed), @order)
69
65
  return nil if correlation[0] <= ENERGY_FLOOR
70
66
 
71
- Lpc.levinson(correlation, order)
67
+ Lpc.levinson(correlation, @order)
72
68
  end
73
69
 
74
70
  def extrema(envelope, sample_rate)
data/lib/dsprb/fourier.rb CHANGED
@@ -6,9 +6,7 @@ module DSP
6
6
  attr_reader :size
7
7
 
8
8
  def initialize(size)
9
- @size = Integer(size)
10
- raise ArgumentError, "FFT size must be a positive power of two, got #{@size}" unless power_of_two?(@size)
11
-
9
+ @size = Check.power_of_two!(Integer(size), "FFT size")
12
10
  @stages = build_stages(@size)
13
11
  freeze
14
12
  end
@@ -25,7 +23,19 @@ module DSP
25
23
  while block < @size
26
24
  index = 0
27
25
  while index < half
28
- butterfly(real, imaginary, block + index, half, cosines[index], sines[index])
26
+ top = block + index
27
+ bottom = top + half
28
+ cosine = cosines[index]
29
+ sine = sines[index]
30
+ real_bottom = real[bottom]
31
+ imaginary_bottom = imaginary[bottom]
32
+ rotated_real = (real_bottom * cosine) - (imaginary_bottom * sine)
33
+ rotated_imaginary = (real_bottom * sine) + (imaginary_bottom * cosine)
34
+
35
+ real[bottom] = real[top] - rotated_real
36
+ imaginary[bottom] = imaginary[top] - rotated_imaginary
37
+ real[top] += rotated_real
38
+ imaginary[top] += rotated_imaginary
29
39
  index += 1
30
40
  end
31
41
 
@@ -40,8 +50,6 @@ module DSP
40
50
 
41
51
  private
42
52
 
43
- def power_of_two?(value) = value.positive? && (value & (value - 1)).zero?
44
-
45
53
  def build_stages(size)
46
54
  stages = {}
47
55
  span = 2
@@ -89,18 +97,84 @@ module DSP
89
97
  source += 1
90
98
  end
91
99
  end
100
+ end
101
+
102
+ # Transform of a real signal. The even and odd samples are packed into one
103
+ # complex sequence of half the length and untangled afterwards, so a real
104
+ # frame costs about half of the complex transform.
105
+ class Real
106
+ MINIMUM_SIZE = 4
107
+
108
+ attr_reader :size, :bins
109
+
110
+ def initialize(size)
111
+ @size = Check.at_least!(Integer(size), MINIMUM_SIZE, "size")
112
+ @half = @size / 2
113
+ @inner = Radix2.new(@half)
114
+ @bins = @half + 1
115
+ @cosines = Array.new(@half) { |bin| Math.cos(-Math::PI * bin / @half) }.freeze
116
+ @sines = Array.new(@half) { |bin| Math.sin(-Math::PI * bin / @half) }.freeze
117
+ freeze
118
+ end
119
+
120
+ # Bins 0..size/2, the non-redundant half. The frame is read up to size
121
+ # samples and zero padded.
122
+ def call(frame)
123
+ real, imaginary = pack(frame)
124
+ @inner.call(real, imaginary)
125
+ untangle(real, imaginary)
126
+ end
127
+
128
+ def power(frame)
129
+ real, imaginary = call(frame)
130
+
131
+ Array.new(@bins) { |bin| (real[bin] * real[bin]) + (imaginary[bin] * imaginary[bin]) }
132
+ end
133
+
134
+ private
135
+
136
+ def pack(frame)
137
+ real = Array.new(@half, 0.0)
138
+ imaginary = Array.new(@half, 0.0)
139
+ limit = frame.length < @size ? frame.length : @size
140
+ index = 0
141
+ while index < limit
142
+ if index.even?
143
+ real[index >> 1] = frame[index].to_f
144
+ else
145
+ imaginary[index >> 1] = frame[index].to_f
146
+ end
147
+
148
+ index += 1
149
+ end
150
+
151
+ [real, imaginary]
152
+ end
153
+
154
+ def untangle(real, imaginary)
155
+ out_real = Array.new(@bins)
156
+ out_imaginary = Array.new(@bins)
157
+
158
+ bin = 0
159
+ while bin <= @half
160
+ low = bin == @half ? 0 : bin
161
+ high = (@half - bin) % @half
162
+
163
+ even_real = (real[low] + real[high]) * 0.5
164
+ even_imaginary = (imaginary[low] - imaginary[high]) * 0.5
165
+ odd_real = (imaginary[low] + imaginary[high]) * 0.5
166
+ odd_imaginary = (real[high] - real[low]) * 0.5
167
+
168
+ # The Nyquist twiddle is -1, which a half length table cannot hold.
169
+ cosine = bin == @half ? -1.0 : @cosines[bin]
170
+ sine = bin == @half ? 0.0 : @sines[bin]
171
+
172
+ out_real[bin] = even_real + ((odd_real * cosine) - (odd_imaginary * sine))
173
+ out_imaginary[bin] = even_imaginary + ((odd_real * sine) + (odd_imaginary * cosine))
174
+ bin += 1
175
+ end
92
176
 
93
- def butterfly(real, imaginary, top, half, cosine, sine)
94
- bottom = top + half
95
- real_bottom = real[bottom]
96
- imaginary_bottom = imaginary[bottom]
97
- rotated_real = (real_bottom * cosine) - (imaginary_bottom * sine)
98
- rotated_imaginary = (real_bottom * sine) + (imaginary_bottom * cosine)
99
-
100
- real[bottom] = real[top] - rotated_real
101
- imaginary[bottom] = imaginary[top] - rotated_imaginary
102
- real[top] += rotated_real
103
- imaginary[top] += rotated_imaginary
177
+ [out_real, out_imaginary]
104
178
  end
105
179
  end
106
180
  end
data/lib/dsprb/framing.rb CHANGED
@@ -8,8 +8,8 @@ module DSP
8
8
  module_function
9
9
 
10
10
  def frames(samples, window:, hop:)
11
- raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
12
- raise ArgumentError, "hop must be positive, got #{hop}" unless hop.positive?
11
+ Check.positive!(window, "window")
12
+ Check.positive!(hop, "hop")
13
13
 
14
14
  out = []
15
15
  position = 0
data/lib/dsprb/lpc.rb CHANGED
@@ -2,51 +2,73 @@
2
2
 
3
3
  module DSP
4
4
  module Lpc
5
- # Below this the all-pole denominator is treated as a pole on the unit circle.
6
5
  DENOMINATOR_FLOOR = 1e-18
7
-
8
6
  UNBOUNDED_MAGNITUDE = 1e9
9
7
 
10
- Model = Data.define(:coefficients, :error) do
11
- def order = coefficients.length - 1
12
-
13
- # Magnitude of the all-pole transfer function 1 / |A(e^{i w})|^2 sampled
14
- # over [0, pi], the spectral envelope the predictor implies.
15
- def envelope(points:)
16
- raise ArgumentError, "points must be at least two, got #{points}" unless points >= 2
17
-
18
- span = points - 1
8
+ # Samples 1 / |A(e^{i w})|^2 over [0, pi] on a fixed grid. Squaring the
9
+ # polynomial first leaves a sum over its autocorrelation, which is real and
10
+ # even, so only cosines remain and the grid precomputes once:
11
+ #
12
+ # |A(w)|^2 = c[0] + 2 sum_{m=1..order} c[m] cos(w m)
13
+ class Envelope
14
+ extend Prepared
15
+
16
+ attr_reader :points, :order
17
+
18
+ def self.for(points:, order:) = prepared([points, order]) { new(points: points, order: order) }
19
+
20
+ def initialize(points:, order:)
21
+ @points = Check.at_least!(Integer(points), 2, "points")
22
+ @order = Check.non_negative!(Integer(order), "order")
23
+
24
+ span = (@points - 1).to_f
25
+ @cosines = Array
26
+ .new(@points) do |point|
27
+ Array.new(@order + 1) { |lag| Math.cos(Math::PI * point * lag / span) }.freeze
28
+ end
29
+ .freeze
30
+ freeze
31
+ end
19
32
 
20
- Array.new(points) do |index|
21
- real, imaginary = response(Math::PI * index / span)
22
- denominator = (real * real) + (imaginary * imaginary)
23
- denominator < DENOMINATOR_FLOOR ? UNBOUNDED_MAGNITUDE : 1.0 / denominator
33
+ def call(coefficients)
34
+ highest = coefficients.length - 1
35
+ if highest > @order
36
+ raise ArgumentError, "expected at most #{@order + 1} coefficients, got #{coefficients.length}"
24
37
  end
25
- end
26
38
 
27
- private
28
-
29
- def response(omega)
30
- real = 0.0
31
- imaginary = 0.0
32
- index = 0
33
- while index < coefficients.length
34
- angle = -omega * index
35
- real += coefficients[index] * Math.cos(angle)
36
- imaginary += coefficients[index] * Math.sin(angle)
37
- index += 1
39
+ correlation = Autocorrelation.call(coefficients, highest)
40
+ out = Array.new(@points)
41
+
42
+ point = 0
43
+ while point < @points
44
+ row = @cosines[point]
45
+ total = correlation[0]
46
+ lag = 1
47
+ while lag <= highest
48
+ total += 2.0 * correlation[lag] * row[lag]
49
+ lag += 1
50
+ end
51
+
52
+ out[point] = total < DENOMINATOR_FLOOR ? UNBOUNDED_MAGNITUDE : 1.0 / total
53
+ point += 1
38
54
  end
39
55
 
40
- [real, imaginary]
56
+ out
41
57
  end
42
58
  end
43
59
 
60
+ Model = Data.define(:coefficients, :error) do
61
+ def order = coefficients.length - 1
62
+
63
+ def envelope(points:) = Envelope.for(points: points, order: order).call(coefficients)
64
+ end
65
+
44
66
  module_function
45
67
 
46
68
  def solve(frame, order:) = levinson(Autocorrelation.call(frame, order), order)
47
69
 
48
- # Levinson-Durbin recursion: solves the Yule-Walker system in O(order^2),
49
- # stopping early when a reflection coefficient leaves the unit circle.
70
+ # Levinson-Durbin recursion, O(order^2), stopping early when a reflection
71
+ # coefficient leaves the unit circle.
50
72
  def levinson(autocorrelation, order)
51
73
  coefficients = Array.new(order + 1, 0.0)
52
74
  coefficients[0] = 1.0
@@ -9,13 +9,12 @@ module DSP
9
9
  DEFAULT_FILTERS = 26
10
10
  DEFAULT_LOW_HZ = 50.0
11
11
 
12
- # Keeps the logarithm of a band that collected no energy finite.
13
12
  ENERGY_FLOOR = 1e-12
14
13
 
15
14
  attr_reader :sample_rate, :size, :filters, :low, :high, :warp, :bands
16
15
 
17
- # warp rescales the band centers for vocal tract length normalization:
18
- # values above 1 shift the bank upwards, suiting shorter vocal tracts.
16
+ # warp rescales the band centers for vocal tract length normalization;
17
+ # above 1 the bank shifts upwards, suiting shorter vocal tracts.
19
18
  def initialize(sample_rate:, size:, filters: DEFAULT_FILTERS, low: DEFAULT_LOW_HZ, high: nil, warp: 1.0)
20
19
  @sample_rate = Float(sample_rate)
21
20
  @size = Integer(size)
@@ -51,9 +50,9 @@ module DSP
51
50
  private
52
51
 
53
52
  def validate!
54
- raise ArgumentError, "filters must be positive, got #{@filters}" unless @filters.positive?
55
- raise ArgumentError, "warp must be positive, got #{@warp}" unless @warp.positive?
56
- raise ArgumentError, "low must be below high, got #{@low} and #{@high}" unless @low < @high
53
+ Check.positive!(@filters, "filters")
54
+ Check.positive!(@warp, "warp")
55
+ Check.below!(@low, @high, "low", "high")
57
56
  end
58
57
 
59
58
  def build_bands
data/lib/dsprb/mfcc.rb CHANGED
@@ -21,12 +21,12 @@ module DSP
21
21
  @coefficients = Integer(coefficients)
22
22
  @spectrum = spectrum || Spectrum.new(filterbank.size)
23
23
 
24
- raise ArgumentError, "coefficients must be positive, got #{@coefficients}" unless @coefficients.positive?
25
-
24
+ Check.positive!(@coefficients, "coefficients")
25
+ @dct = Dct::Two.for(@filterbank.filters, @coefficients)
26
26
  freeze
27
27
  end
28
28
 
29
- def call(power) = Dct.two(@filterbank.log_energies(power), @coefficients)
29
+ def call(power) = @dct.call(@filterbank.log_energies(power))
30
30
 
31
31
  def sequence(
32
32
  waveform,
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ # Shares one instance per parameter set. What a stage precomputes depends on
5
+ # nothing but its own sizes, so framing a signal need not rebuild it per frame.
6
+ module Prepared
7
+ def self.extended(base)
8
+ base.instance_variable_set(:@prepared, {})
9
+ base.instance_variable_set(:@prepared_mutex, Mutex.new)
10
+ end
11
+
12
+ def prepared(key)
13
+ found = @prepared[key]
14
+ return found unless found.nil?
15
+
16
+ @prepared_mutex.synchronize { @prepared[key] ||= yield }
17
+ end
18
+
19
+ def prepared_count = @prepared.size
20
+ end
21
+ end
@@ -4,17 +4,30 @@ module DSP
4
4
  class Spectrum
5
5
  attr_reader :size, :bins, :transform
6
6
 
7
+ # Defaults to the real-input transform; a complex one such as
8
+ # Fourier::Radix2 may be injected instead.
7
9
  def initialize(size, transform: nil)
8
10
  @size = Integer(size)
9
- @transform = transform || Fourier::Radix2.new(@size)
10
11
  @bins = (@size / 2) + 1
12
+ @transform = transform || Fourier::Real.new(@size)
13
+ @real_input = @transform.respond_to?(:power)
11
14
  freeze
12
15
  end
13
16
 
14
- def power(frame)
17
+ def power(frame) = @real_input ? @transform.power(frame) : complex_power(frame)
18
+
19
+ def magnitude(frame) = power(frame).map { |value| Math.sqrt(value) }
20
+
21
+ def frequencies(sample_rate) = Array.new(@bins) { |bin| bin * sample_rate / @size.to_f }
22
+
23
+ def bin_of(frequency, sample_rate) = (frequency * @size / sample_rate.to_f).round
24
+
25
+ private
26
+
27
+ def complex_power(frame)
15
28
  real = Array.new(@size, 0.0)
16
29
  imaginary = Array.new(@size, 0.0)
17
- limit = [frame.length, @size].min
30
+ limit = frame.length < @size ? frame.length : @size
18
31
  index = 0
19
32
  while index < limit
20
33
  real[index] = frame[index].to_f
@@ -25,11 +38,5 @@ module DSP
25
38
 
26
39
  Array.new(@bins) { |bin| (real[bin] * real[bin]) + (imaginary[bin] * imaginary[bin]) }
27
40
  end
28
-
29
- def magnitude(frame) = power(frame).map { |value| Math.sqrt(value) }
30
-
31
- def frequencies(sample_rate) = Array.new(@bins) { |bin| bin * sample_rate / @size.to_f }
32
-
33
- def bin_of(frequency, sample_rate) = (frequency * @size / sample_rate.to_f).round
34
41
  end
35
42
  end
data/lib/dsprb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSP
4
- VERSION = "1.0.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/dsprb/wav.rb CHANGED
@@ -12,7 +12,7 @@ module DSP
12
12
  HEADER_BYTES = 12
13
13
  CHUNK_HEADER_BYTES = 8
14
14
 
15
- # Unsigned 8-bit PCM is the one depth WAVE stores with a midpoint offset.
15
+ # Unsigned 8-bit is the one depth WAVE stores with a midpoint offset.
16
16
  EIGHT_BIT_MIDPOINT = 128.0
17
17
 
18
18
  module_function
@@ -3,10 +3,7 @@
3
3
  module DSP
4
4
  Waveform = Data.define(:samples, :sample_rate) do
5
5
  def initialize(samples:, sample_rate:)
6
- rate = Float(sample_rate)
7
- raise ArgumentError, "sample rate must be positive, got #{rate}" unless rate.positive?
8
-
9
- super(samples: samples, sample_rate: rate)
6
+ super(samples: samples, sample_rate: Check.positive!(Float(sample_rate), "sample rate"))
10
7
  end
11
8
 
12
9
  def length = samples.length
data/lib/dsprb/window.rb CHANGED
@@ -2,17 +2,19 @@
2
2
 
3
3
  module DSP
4
4
  module Window
5
- # Generalized cosine windows w[i] = sum_k (-1)^k a_k cos(2 pi k i / (N - 1)).
5
+ # w[i] = sum_k (-1)^k a_k cos(2 pi k i / (N - 1)).
6
6
  HAMMING = [0.54, 0.46].freeze
7
7
  HANN = [0.5, 0.5].freeze
8
8
  BLACKMAN = [0.42, 0.5, 0.08].freeze
9
9
 
10
+ extend Prepared
11
+
10
12
  module_function
11
13
 
12
14
  def rectangular(length)
13
15
  assert_length!(length)
14
16
 
15
- Array.new(length, 1.0)
17
+ Window.prepared([:rectangular, length]) { Array.new(length, 1.0).freeze }
16
18
  end
17
19
 
18
20
  def hamming(length) = cosine_sum(length, HAMMING)
@@ -23,13 +25,26 @@ module DSP
23
25
 
24
26
  def cosine_sum(length, coefficients)
25
27
  assert_length!(length)
28
+
29
+ Window.prepared([:cosine_sum, length, coefficients]) { build_cosine_sum(length, coefficients).freeze }
30
+ end
31
+
32
+ def build_cosine_sum(length, coefficients)
26
33
  return [1.0] if length == 1
27
34
 
28
35
  span = (length - 1).to_f
36
+ terms = coefficients.length
29
37
 
30
38
  Array.new(length) do |index|
31
39
  phase = 2.0 * Math::PI * index / span
32
- coefficients.each_with_index.sum { |weight, term| (term.even? ? weight : -weight) * Math.cos(term * phase) }
40
+ total = 0.0
41
+ term = 0
42
+ while term < terms
43
+ total += (term.even? ? coefficients[term] : -coefficients[term]) * Math.cos(term * phase)
44
+ term += 1
45
+ end
46
+
47
+ total
33
48
  end
34
49
  end
35
50
 
data/lib/dsprb/yin.rb CHANGED
@@ -13,22 +13,31 @@ module DSP
13
13
  # A global minimum above this is too shallow to call the frame voiced.
14
14
  DEFAULT_VOICED_THRESHOLD = 0.35
15
15
 
16
- # Decimation target, an order above twice the highest tracked f0, so that
17
- # periodicity survives while the O(window * lags) search stays cheap.
16
+ # An order above twice the highest tracked f0, so periodicity survives while
17
+ # the O(window * lags) search stays cheap.
18
18
  DEFAULT_WORKING_RATE = 5512.5
19
19
 
20
- # Bounds the lag search for pathological rate and frequency combinations.
21
20
  MAXIMUM_LAG = 10_000
22
21
 
23
- attr_reader :minimum_frequency, :maximum_frequency, :hop_seconds, :threshold, :voiced_threshold, :working_rate
22
+ attr_reader(
23
+ :minimum_frequency,
24
+ :maximum_frequency,
25
+ :hop_seconds,
26
+ :threshold,
27
+ :voiced_threshold,
28
+ :working_rate,
29
+ :minimum_rms
30
+ )
24
31
 
32
+ # minimum_rms reports frames quieter than it unvoiced without searching.
25
33
  def initialize(
26
34
  minimum_frequency: DEFAULT_MINIMUM_HZ,
27
35
  maximum_frequency: DEFAULT_MAXIMUM_HZ,
28
36
  hop_seconds: DEFAULT_HOP_SECONDS,
29
37
  threshold: DEFAULT_THRESHOLD,
30
38
  voiced_threshold: DEFAULT_VOICED_THRESHOLD,
31
- working_rate: DEFAULT_WORKING_RATE
39
+ working_rate: DEFAULT_WORKING_RATE,
40
+ minimum_rms: 0.0
32
41
  )
33
42
  @minimum_frequency = Float(minimum_frequency)
34
43
  @maximum_frequency = Float(maximum_frequency)
@@ -36,13 +45,14 @@ module DSP
36
45
  @threshold = Float(threshold)
37
46
  @voiced_threshold = Float(voiced_threshold)
38
47
  @working_rate = Float(working_rate)
48
+ @minimum_rms = Float(minimum_rms)
39
49
 
40
50
  validate!
41
51
  freeze
42
52
  end
43
53
 
44
54
  def call(waveform)
45
- reduced = Decimator.new(Decimator.factor_for(waveform.sample_rate, @working_rate)).call(waveform)
55
+ reduced = Decimator.to(waveform, @working_rate)
46
56
  rate = reduced.sample_rate
47
57
  shortest = (rate / @maximum_frequency).floor.clamp(2, MAXIMUM_LAG)
48
58
  longest = (rate / @minimum_frequency).ceil.clamp(shortest + 2, MAXIMUM_LAG)
@@ -63,8 +73,9 @@ module DSP
63
73
  )
64
74
  end
65
75
 
66
- raise ArgumentError, "hop_seconds must be positive, got #{@hop_seconds}" unless @hop_seconds.positive?
67
- raise ArgumentError, "working_rate must be positive, got #{@working_rate}" unless @working_rate.positive?
76
+ Check.positive!(@hop_seconds, "hop_seconds")
77
+ Check.positive!(@working_rate, "working_rate")
78
+ Check.non_negative!(@minimum_rms, "minimum_rms")
68
79
  end
69
80
 
70
81
  def track(samples, rate:, window:, hop:, shortest:, longest:)
@@ -74,10 +85,16 @@ module DSP
74
85
 
75
86
  position = 0
76
87
  while position + window + longest <= samples.length
77
- normalized = cumulative_mean_difference(samples, position, window, shortest, longest)
78
- frequency, weight = evaluate(normalized, select_lag(normalized, shortest, longest), shortest, longest, rate)
79
- f0 << frequency
80
- confidence << weight
88
+ if quiet?(samples, position, window)
89
+ f0 << 0.0
90
+ confidence << 0.0
91
+ else
92
+ normalized, lag = search(samples, position, window, shortest, longest)
93
+ frequency, weight = evaluate(normalized, lag, shortest, longest, rate)
94
+ f0 << frequency
95
+ confidence << weight
96
+ end
97
+
81
98
  times << (position / rate)
82
99
  position += hop
83
100
  end
@@ -85,67 +102,68 @@ module DSP
85
102
  PitchTrack.new(f0: f0, confidence: confidence, times: times, hop_seconds: hop / rate)
86
103
  end
87
104
 
88
- def cumulative_mean_difference(samples, position, window, shortest, longest)
89
- raw = squared_difference(samples, position, window, shortest, longest)
90
- normalized = Array.new(longest + 1, 1.0)
91
- running = 0.0
105
+ def quiet?(samples, position, window)
106
+ return false unless @minimum_rms.positive?
92
107
 
93
- lag = shortest
94
- while lag <= longest
95
- running += raw[lag]
96
- normalized[lag] = running.zero? ? 1.0 : raw[lag] * (lag - shortest + 1) / running
97
- lag += 1
108
+ total = 0.0
109
+ index = 0
110
+ while index < window
111
+ value = samples[position + index]
112
+ total += value * value
113
+ index += 1
98
114
  end
99
115
 
100
- normalized
116
+ Math.sqrt(total / window) < @minimum_rms
101
117
  end
102
118
 
103
- def squared_difference(samples, position, window, shortest, longest)
104
- raw = Array.new(longest + 1, 0.0)
119
+ # The cumulative mean normalization only looks back, so lags accumulate in
120
+ # order and the walk stops at the first qualifying dip. A frame without one
121
+ # has filled the whole array by the end, which the global minimum needs.
122
+ def search(samples, position, window, shortest, longest)
123
+ normalized = Array.new(longest + 1, 1.0)
124
+ running = 0.0
125
+ best = shortest
126
+ lowest = Float::INFINITY
127
+ chosen = nil
105
128
 
106
129
  lag = shortest
107
130
  while lag <= longest
108
- total = 0.0
109
- index = 0
110
- while index < window
111
- delta = samples[position + index] - samples[position + index + lag]
112
- total += delta * delta
113
- index += 1
114
- end
115
-
116
- raw[lag] = total
117
- lag += 1
118
- end
131
+ raw = difference(samples, position, window, lag)
132
+ running += raw
133
+ value = running.zero? ? 1.0 : raw * (lag - shortest + 1) / running
134
+ normalized[lag] = value
119
135
 
120
- raw
121
- end
136
+ if value < lowest
137
+ lowest = value
138
+ best = lag
139
+ end
122
140
 
123
- def select_lag(normalized, shortest, longest)
124
- lag = shortest + 1
125
- while lag < longest
126
- return lag if normalized[lag] < @threshold && normalized[lag] <= normalized[lag + 1]
141
+ previous = lag - 1
142
+ if chosen.nil? &&
143
+ previous > shortest &&
144
+ previous < longest &&
145
+ normalized[previous] < @threshold &&
146
+ normalized[previous] <= value
147
+ chosen = previous
148
+ break
149
+ end
127
150
 
128
151
  lag += 1
129
152
  end
130
153
 
131
- global_minimum(normalized, shortest, longest)
154
+ [normalized, chosen || best]
132
155
  end
133
156
 
134
- def global_minimum(normalized, shortest, longest)
135
- best = shortest
136
- lowest = normalized[shortest]
137
-
138
- lag = shortest + 1
139
- while lag <= longest
140
- if normalized[lag] < lowest
141
- lowest = normalized[lag]
142
- best = lag
143
- end
144
-
145
- lag += 1
157
+ def difference(samples, position, window, lag)
158
+ total = 0.0
159
+ index = 0
160
+ while index < window
161
+ delta = samples[position + index] - samples[position + index + lag]
162
+ total += delta * delta
163
+ index += 1
146
164
  end
147
165
 
148
- best
166
+ total
149
167
  end
150
168
 
151
169
  def evaluate(normalized, lag, shortest, longest, rate)
data/lib/dsprb.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "dsprb/version"
4
4
  require_relative "dsprb/errors"
5
+ require_relative "dsprb/check"
6
+ require_relative "dsprb/prepared"
5
7
  require_relative "dsprb/resolver"
6
8
  require_relative "dsprb/parabolic"
7
9
  require_relative "dsprb/waveform"
data/sig/dsprb.rbs CHANGED
@@ -43,6 +43,30 @@ module DSP
43
43
  **untyped
44
44
  ) -> ::Array[::Array[::Float]]
45
45
 
46
+ module Check
47
+ def self.positive!: [T < _Positive] (T, ::String) -> T
48
+ def self.non_negative!: [T < _Negative] (T, ::String) -> T
49
+ def self.at_least!: [T < ::Comparable] (T, T, ::String) -> T
50
+ def self.power_of_two!: (::Integer, ::String) -> ::Integer
51
+ def self.below!: [T < ::Comparable] (T, T, ::String, ::String) -> T
52
+ def self.present!: [T] (T, ::String) -> T
53
+ end
54
+
55
+ interface _Positive
56
+ def positive?: () -> bool
57
+ end
58
+
59
+ interface _Negative
60
+ def negative?: () -> bool
61
+ end
62
+
63
+ module Prepared
64
+ def self.extended: (untyped) -> void
65
+
66
+ def prepared: [T] (untyped) { () -> T } -> T
67
+ def prepared_count: () -> ::Integer
68
+ end
69
+
46
70
  module Resolver
47
71
  def self.call: (untyped, ::Hash[::Symbol, untyped], ::String) -> untyped
48
72
  end
@@ -86,6 +110,8 @@ module DSP
86
110
  end
87
111
 
88
112
  module Window
113
+ extend Prepared
114
+
89
115
  HAMMING: ::Array[::Float]
90
116
  HANN: ::Array[::Float]
91
117
  BLACKMAN: ::Array[::Float]
@@ -97,6 +123,7 @@ module DSP
97
123
  def self.hann: (::Integer) -> ::Array[::Float]
98
124
  def self.blackman: (::Integer) -> ::Array[::Float]
99
125
  def self.cosine_sum: (::Integer, ::Array[::Float]) -> ::Array[::Float]
126
+ def self.build_cosine_sum: (::Integer, ::Array[::Float]) -> ::Array[::Float]
100
127
  def self.apply: (frame, ::Array[::Float]) -> ::Array[::Float]
101
128
  def self.assert_length!: (untyped) -> void
102
129
  def self.resolve: (window) -> _WindowFunction
@@ -117,6 +144,17 @@ module DSP
117
144
  def initialize: (::Integer) -> void
118
145
  def call: (::Array[::Float], ::Array[::Float]) -> [::Array[::Float], ::Array[::Float]]
119
146
  end
147
+
148
+ class Real
149
+ MINIMUM_SIZE: ::Integer
150
+
151
+ attr_reader size: ::Integer
152
+ attr_reader bins: ::Integer
153
+
154
+ def initialize: (::Integer) -> void
155
+ def call: (frame) -> [::Array[::Float], ::Array[::Float]]
156
+ def power: (frame) -> spectrum
157
+ end
120
158
  end
121
159
 
122
160
  class Spectrum
@@ -178,6 +216,17 @@ module DSP
178
216
  end
179
217
 
180
218
  module Dct
219
+ class Two
220
+ extend Prepared
221
+
222
+ attr_reader length: ::Integer
223
+ attr_reader count: ::Integer
224
+
225
+ def self.for: (::Integer, ::Integer) -> Two
226
+ def initialize: (::Integer, ::Integer) -> void
227
+ def call: (::Array[::Numeric]) -> ::Array[::Float]
228
+ end
229
+
181
230
  def self.two: (::Array[::Numeric], ::Integer) -> ::Array[::Float]
182
231
  end
183
232
 
@@ -205,6 +254,8 @@ module DSP
205
254
  end
206
255
 
207
256
  class Decimator
257
+ extend Prepared
258
+
208
259
  DEFAULT_TAPS: ::Integer
209
260
  CUTOFF_RATIO: ::Float
210
261
 
@@ -213,6 +264,8 @@ module DSP
213
264
  attr_reader kernel: ::Array[::Float]
214
265
 
215
266
  def self.factor_for: (::Numeric, ::Numeric) -> ::Integer
267
+ def self.for: (::Integer, ?taps: ::Integer) -> Decimator
268
+ def self.to: (Waveform, ::Numeric, ?taps: ::Integer) -> Waveform
216
269
  def initialize: (::Integer, ?taps: ::Integer) -> void
217
270
  def call: (Waveform) -> Waveform
218
271
  end
@@ -225,6 +278,17 @@ module DSP
225
278
  DENOMINATOR_FLOOR: ::Float
226
279
  UNBOUNDED_MAGNITUDE: ::Float
227
280
 
281
+ class Envelope
282
+ extend Prepared
283
+
284
+ attr_reader points: ::Integer
285
+ attr_reader order: ::Integer
286
+
287
+ def self.for: (points: ::Integer, order: ::Integer) -> Envelope
288
+ def initialize: (points: ::Integer, order: ::Integer) -> void
289
+ def call: (::Array[::Float]) -> ::Array[::Float]
290
+ end
291
+
228
292
  class Model
229
293
  attr_reader coefficients: ::Array[::Float]
230
294
  attr_reader error: ::Float
@@ -261,6 +325,7 @@ module DSP
261
325
  attr_reader resolution: ::Integer
262
326
  attr_reader separation: ::Float
263
327
  attr_reader edge_margin: ::Float
328
+ attr_reader order: ::Integer
264
329
 
265
330
  def initialize: (
266
331
  ?maximum_frequency: ::Numeric,
@@ -311,6 +376,7 @@ module DSP
311
376
  attr_reader threshold: ::Float
312
377
  attr_reader voiced_threshold: ::Float
313
378
  attr_reader working_rate: ::Float
379
+ attr_reader minimum_rms: ::Float
314
380
 
315
381
  def initialize: (
316
382
  ?minimum_frequency: ::Numeric,
@@ -318,6 +384,7 @@ module DSP
318
384
  ?hop_seconds: ::Numeric,
319
385
  ?threshold: ::Numeric,
320
386
  ?voiced_threshold: ::Numeric,
387
+ ?minimum_rms: ::Numeric,
321
388
  ?working_rate: ::Numeric
322
389
  ) -> void
323
390
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dsprb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Den Patin
@@ -68,6 +68,7 @@ files:
68
68
  - lib/dsprb.rb
69
69
  - lib/dsprb/autocorrelation.rb
70
70
  - lib/dsprb/biquad.rb
71
+ - lib/dsprb/check.rb
71
72
  - lib/dsprb/curve.rb
72
73
  - lib/dsprb/dct.rb
73
74
  - lib/dsprb/decimator.rb
@@ -81,6 +82,7 @@ files:
81
82
  - lib/dsprb/mfcc.rb
82
83
  - lib/dsprb/parabolic.rb
83
84
  - lib/dsprb/pitch_track.rb
85
+ - lib/dsprb/prepared.rb
84
86
  - lib/dsprb/resolver.rb
85
87
  - lib/dsprb/scales.rb
86
88
  - lib/dsprb/spectral_moments.rb
@@ -113,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
115
  - !ruby/object:Gem::Version
114
116
  version: '0'
115
117
  requirements: []
116
- rubygems_version: 4.0.16
118
+ rubygems_version: 4.0.17
117
119
  specification_version: 4
118
120
  summary: Speech signal processing and acoustic feature extraction for numeric signals
119
121
  test_files: []