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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +20 -6
- data/lib/dsprb/autocorrelation.rb +2 -2
- data/lib/dsprb/biquad.rb +3 -4
- data/lib/dsprb/check.rb +43 -0
- data/lib/dsprb/curve.rb +3 -3
- data/lib/dsprb/dct.rb +34 -14
- data/lib/dsprb/decimator.rb +36 -17
- data/lib/dsprb/energy.rb +4 -4
- data/lib/dsprb/formants.rb +10 -14
- data/lib/dsprb/fourier.rb +91 -17
- data/lib/dsprb/framing.rb +2 -2
- data/lib/dsprb/lpc.rb +52 -30
- data/lib/dsprb/mel_filterbank.rb +5 -6
- data/lib/dsprb/mfcc.rb +3 -3
- data/lib/dsprb/prepared.rb +21 -0
- data/lib/dsprb/spectrum.rb +16 -9
- data/lib/dsprb/version.rb +1 -1
- data/lib/dsprb/wav.rb +1 -1
- data/lib/dsprb/waveform.rb +1 -4
- data/lib/dsprb/window.rb +18 -3
- data/lib/dsprb/yin.rb +73 -55
- data/lib/dsprb.rb +2 -0
- data/sig/dsprb.rbs +67 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa50ef5e7332169aacc296720f303cc7f5160a43e9bc56f7fd964efd86b90404
|
|
4
|
+
data.tar.gz: fb948d7001cc0f53e39d09146c71c6b79dc90cb2d1945e5b78b99e8405ffd875
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
186
|
-
`#
|
|
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
|
-
#
|
|
7
|
+
# r[k] = sum_i x[i] x[i + k], k = 0..order, without a denominator.
|
|
8
8
|
def call(frame, order)
|
|
9
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
59
|
+
Check.positive!(sections, "sections")
|
|
61
60
|
|
|
62
61
|
sections.times.reduce(samples) { |signal, _| call(signal) }
|
|
63
62
|
end
|
data/lib/dsprb/check.rb
ADDED
|
@@ -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
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
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
|
-
|
|
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
|
data/lib/dsprb/decimator.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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 <
|
|
66
|
+
while position < length
|
|
67
|
+
first = position - @middle
|
|
58
68
|
total = 0.0
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
37
|
+
Check.positive!(window, "window")
|
|
38
|
+
Check.positive!(hop, "hop")
|
|
39
39
|
return [] if samples.length < window
|
|
40
40
|
|
|
41
41
|
running = 0.0
|
data/lib/dsprb/formants.rb
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
#
|
|
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
|
data/lib/dsprb/mel_filterbank.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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) =
|
|
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
|
data/lib/dsprb/spectrum.rb
CHANGED
|
@@ -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 =
|
|
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
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
|
|
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
|
data/lib/dsprb/waveform.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
116
|
+
Math.sqrt(total / window) < @minimum_rms
|
|
101
117
|
end
|
|
102
118
|
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
121
|
-
|
|
136
|
+
if value < lowest
|
|
137
|
+
lowest = value
|
|
138
|
+
best = lag
|
|
139
|
+
end
|
|
122
140
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
154
|
+
[normalized, chosen || best]
|
|
132
155
|
end
|
|
133
156
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
166
|
+
total
|
|
149
167
|
end
|
|
150
168
|
|
|
151
169
|
def evaluate(normalized, lag, shortest, longest, rate)
|
data/lib/dsprb.rb
CHANGED
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:
|
|
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.
|
|
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: []
|