dsprb 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +13 -0
- data/README.md +193 -0
- data/lib/dsprb/autocorrelation.rb +26 -0
- data/lib/dsprb/biquad.rb +65 -0
- data/lib/dsprb/curve.rb +26 -0
- data/lib/dsprb/dct.rb +24 -0
- data/lib/dsprb/decimator.rb +73 -0
- data/lib/dsprb/energy.rb +72 -0
- data/lib/dsprb/errors.rb +9 -0
- data/lib/dsprb/formants.rb +112 -0
- data/lib/dsprb/fourier.rb +107 -0
- data/lib/dsprb/framing.rb +44 -0
- data/lib/dsprb/lpc.rb +77 -0
- data/lib/dsprb/mel_filterbank.rb +96 -0
- data/lib/dsprb/mfcc.rb +60 -0
- data/lib/dsprb/parabolic.rb +18 -0
- data/lib/dsprb/pitch_track.rb +23 -0
- data/lib/dsprb/resolver.rb +19 -0
- data/lib/dsprb/scales.rb +21 -0
- data/lib/dsprb/spectral_moments.rb +80 -0
- data/lib/dsprb/spectrum.rb +35 -0
- data/lib/dsprb/version.rb +5 -0
- data/lib/dsprb/wav.rb +113 -0
- data/lib/dsprb/waveform.rb +24 -0
- data/lib/dsprb/window.rb +55 -0
- data/lib/dsprb/yin.rb +162 -0
- data/lib/dsprb.rb +65 -0
- data/sig/dsprb.rbs +385 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f9551a9c99d503314354a81f6b445eef6c0c29181d55907581856bc1904c2494
|
|
4
|
+
data.tar.gz: ebd353edf01ff2a5f1e5694d74f781858a0f7640861e3cd502ce127e6e48b6b5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3a33aeb3039a94a7edbbede3b95514cffca4e49612c1b1d6f241a1034a5ae5a558eead65f286cc58596466578baf18841b4bf703f027d379a01b96c52b7e39e7
|
|
7
|
+
data.tar.gz: 673fad7a2420db7c4463edd9b68dd776f8b955b3374a9586e721a993ba648e20df63bd1ac2b67228b1a0cd3a29b223ba1e6ab64b486ed14b7c139ca3eb4b0013
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-08-01
|
|
4
|
+
|
|
5
|
+
Initial public release.
|
|
6
|
+
|
|
7
|
+
- WAV decoding for 8, 16, 24 and 32 bit PCM and 32 and 64 bit IEEE float, including
|
|
8
|
+
WAVE_FORMAT_EXTENSIBLE, with channel downmixing (`DSP::Wav`, `DSP::Waveform`)
|
|
9
|
+
- Radix-2 FFT with precomputed twiddle factors and power spectra (`DSP::Fourier`, `DSP::Spectrum`)
|
|
10
|
+
- Generalized cosine windows: Hamming, Hann, Blackman, rectangular (`DSP::Window`)
|
|
11
|
+
- Framing and first-order preemphasis (`DSP::Framing`)
|
|
12
|
+
- Mel and semitone scales, mel filterbanks with vocal tract length warping, DCT-II and MFCC
|
|
13
|
+
(`DSP::Scales`, `DSP::MelFilterbank`, `DSP::Dct`, `DSP::Mfcc`)
|
|
14
|
+
- YIN fundamental frequency estimation with parabolic period refinement (`DSP::Yin`,
|
|
15
|
+
`DSP::PitchTrack`)
|
|
16
|
+
- Linear predictive coding by the Levinson-Durbin recursion, with the all-pole spectral envelope
|
|
17
|
+
and formant extraction (`DSP::Autocorrelation`, `DSP::Lpc`, `DSP::Formants`)
|
|
18
|
+
- Spectral moments, short-term energy, zero crossing rate and decibel envelopes
|
|
19
|
+
(`DSP::SpectralMoments`, `DSP::Energy`)
|
|
20
|
+
- Butterworth biquads and windowed-sinc decimation (`DSP::Biquad`, `DSP::Decimator`)
|
|
21
|
+
- Linear contour resampling (`DSP::Curve`)
|
|
22
|
+
- RBS signatures under `sig/`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
2
|
+
Version 2, December 2004
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026 Den Patin <hi@dpat.in>
|
|
5
|
+
|
|
6
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
|
7
|
+
copies of this license document, and changing it is allowed as long
|
|
8
|
+
as the name is changed.
|
|
9
|
+
|
|
10
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
11
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
12
|
+
|
|
13
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
data/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# dsprb
|
|
2
|
+
|
|
3
|
+
Speech signal processing and acoustic feature extraction in pure Ruby. No dependencies, no native
|
|
4
|
+
extensions, no pseudo-random number generator anywhere.
|
|
5
|
+
|
|
6
|
+
Extracted from [taiwancards](https://github.com/taiwancards/taiwancards), where it turns learner
|
|
7
|
+
recordings into the feature sequences its pronunciation scorer compares.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
A waveform is a poor representation for comparison. Two recordings of the same syllable differ in
|
|
12
|
+
amplitude, in microphone response, in speaking rate, and in every sample value, while agreeing on
|
|
13
|
+
everything a listener perceives: the pitch contour, the resonances of the vocal tract, the noise
|
|
14
|
+
spectrum of the fricatives.
|
|
15
|
+
|
|
16
|
+
Extracting those invariants is the work of acoustic phonetics, and it decomposes into a small set
|
|
17
|
+
of classical algorithms. This library implements them directly: framing and windowing, the discrete
|
|
18
|
+
Fourier transform, mel filterbanks and the cepstrum, the YIN period estimator, linear prediction
|
|
19
|
+
and its all-pole envelope, spectral moments, and the filters that condition a signal beforehand.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "dsprb"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Waveforms
|
|
28
|
+
|
|
29
|
+
`DSP::Wav` decodes 8, 16, 24 and 32 bit PCM as well as 32 and 64 bit IEEE float, including
|
|
30
|
+
`WAVE_FORMAT_EXTENSIBLE`, and downmixes multi-channel streams to mono. Everything downstream takes
|
|
31
|
+
a `DSP::Waveform`, so the sample rate travels with the samples and cannot be mismatched by accident.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "dsprb"
|
|
35
|
+
|
|
36
|
+
signal = DSP.read("utterance.wav")
|
|
37
|
+
|
|
38
|
+
signal.length # => 16000
|
|
39
|
+
signal.sample_rate # => 16000.0
|
|
40
|
+
signal.duration # => 1.0
|
|
41
|
+
signal.nyquist # => 8000.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Signals synthesized in memory are ordinary arrays:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
signal = DSP::Waveform.new(samples: samples, sample_rate: 16_000)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Fundamental frequency
|
|
51
|
+
|
|
52
|
+
`DSP::Yin` implements the YIN estimator: squared difference over lag, cumulative mean
|
|
53
|
+
normalization, absolute-threshold period selection, and parabolic refinement of the chosen minimum.
|
|
54
|
+
The signal is decimated to a working rate before the search, which is what makes an O(window × lag)
|
|
55
|
+
method affordable in Ruby.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
track = DSP.pitch(signal)
|
|
59
|
+
|
|
60
|
+
track.length # => 120
|
|
61
|
+
track.hop_seconds # => 0.008
|
|
62
|
+
track.voiced_ratio # => 1.0
|
|
63
|
+
track.voiced_f0 # => [196.08, ...]
|
|
64
|
+
|
|
65
|
+
track.each_voiced { |frequency, time, confidence| ... }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Unvoiced frames report `0.0` rather than `nil`, so a track is always a dense numeric sequence.
|
|
69
|
+
Confidence is `1 - d'(τ)`, the depth of the selected minimum.
|
|
70
|
+
|
|
71
|
+
## Cepstral features
|
|
72
|
+
|
|
73
|
+
`DSP::Mfcc` composes a power spectrum, a mel filterbank and a DCT-II. The filterbank supports
|
|
74
|
+
vocal tract length warping, which rescales the band centers to compensate for speaker anatomy.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
frames = DSP.mfcc(signal)
|
|
78
|
+
|
|
79
|
+
frames.length # => 98
|
|
80
|
+
frames.first.length # => 13
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The pieces stay separately usable, so a log-mel spectrogram costs one call:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
spectrum = DSP::Spectrum.new(512)
|
|
87
|
+
filterbank = DSP::MelFilterbank.new(sample_rate: 16_000, size: 512, filters: 26, warp: 1.05)
|
|
88
|
+
|
|
89
|
+
log_mel = signal.frames(window: 400, hop: 160).map do |frame|
|
|
90
|
+
filterbank.log_energies(spectrum.power(DSP::Window.apply(frame, DSP::Window.hamming(400))))
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Resonances
|
|
95
|
+
|
|
96
|
+
`DSP::Lpc` fits an all-pole model by the Levinson-Durbin recursion and exposes the spectral
|
|
97
|
+
envelope it implies. `DSP::Formants` picks the envelope maxima, refines them parabolically on the
|
|
98
|
+
log magnitude, and merges peaks that a single resonance split in two.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
DSP.formants(vowel)
|
|
102
|
+
# => [752.8, 2335.8, 3695.6, 5416.1]
|
|
103
|
+
|
|
104
|
+
DSP::Formants.new.peaks(vowel).first.amplitude
|
|
105
|
+
# => 2.91
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The predictor itself is available when the envelope matters more than the peaks:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
model = DSP::Lpc.solve(frame, order: 10)
|
|
112
|
+
|
|
113
|
+
model.order # => 10
|
|
114
|
+
model.error # => residual energy of the prediction
|
|
115
|
+
model.envelope(points: 512)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Spectral moments
|
|
119
|
+
|
|
120
|
+
Centroid, spread, skewness and excess kurtosis of the power spectrum over a chosen band. These are
|
|
121
|
+
the standard descriptors of fricative noise, where place of articulation shifts the centroid.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
moments = DSP::SpectralMoments.of(power, sample_rate: 16_000, size: 1024, low: 500.0)
|
|
125
|
+
|
|
126
|
+
moments.centroid # => 4904.8
|
|
127
|
+
moments.spread # => 1064.8
|
|
128
|
+
moments.skewness # => 0.742
|
|
129
|
+
moments.kurtosis # => -0.708
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Energy, filters, resampling
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
DSP::Energy.frame_db(frame) # root mean square, in decibels
|
|
136
|
+
DSP::Energy.zero_crossing_rate(frame) # crossings per sample interval
|
|
137
|
+
DSP::Energy.envelope_db(samples, window: 400, hop: 160) # sliding energy, one value per hop
|
|
138
|
+
|
|
139
|
+
DSP.highpass(signal, cutoff: 60.0) # removes rumble and DC offset
|
|
140
|
+
DSP.lowpass(signal, cutoff: 4000.0, sections: 2) # cascaded Butterworth sections
|
|
141
|
+
DSP.decimate(signal, 4) # windowed-sinc anti-aliasing
|
|
142
|
+
|
|
143
|
+
DSP::Curve.resample(contour, 16) # contours onto a common grid
|
|
144
|
+
DSP::Scales.hz_to_semitones(392.0, reference: 196.0) # => 12.0
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## In practice
|
|
148
|
+
|
|
149
|
+
[taiwancards](https://github.com/taiwancards/taiwancards) teaches Taiwanese Mandarin and scores
|
|
150
|
+
learner recordings against native templates. Its analysis stage is a pipeline over this library.
|
|
151
|
+
|
|
152
|
+
**Conditioning.** The recording is high-passed to strip rumble and any DC offset, then the energy
|
|
153
|
+
envelope and zero crossing rate locate the speech boundaries and the syllable onsets.
|
|
154
|
+
|
|
155
|
+
**Tone.** Mandarin lexical tone *is* the fundamental frequency contour, so `DSP.pitch` carries the
|
|
156
|
+
primary signal rather than a secondary cue. The track is cleaned of octave errors, then
|
|
157
|
+
`DSP::Curve.resample` brings contours of different duration onto a fixed 16-point grid where the
|
|
158
|
+
four tones become comparable shapes.
|
|
159
|
+
|
|
160
|
+
**Timbre.** Thirteen MFCC per frame describe the spectral envelope independently of pitch. Vocal
|
|
161
|
+
tract length warping is set per speaker, so a child and an adult producing the same vowel land in
|
|
162
|
+
the same region of the feature space.
|
|
163
|
+
|
|
164
|
+
**Vowel quality.** The first two formants place a vowel on the F1/F2 plane, which is where the
|
|
165
|
+
contrast between ㄧ, ㄩ and ㄨ actually lives.
|
|
166
|
+
|
|
167
|
+
**Fricatives.** Spectral moments separate the sibilants: the retroflex ㄕ and the alveolar ㄙ differ
|
|
168
|
+
mainly in centroid and skewness, not in duration or energy.
|
|
169
|
+
|
|
170
|
+
The resulting sequences vary in length because speaking rate varies, so they are aligned and
|
|
171
|
+
averaged with [dtwrb](https://github.com/taiwancards/dtwrb), the companion library for dynamic time
|
|
172
|
+
warping and barycenter averaging.
|
|
173
|
+
|
|
174
|
+
Speech is one application. Anything sampled as a numeric signal — vibration, biosignals, sonar,
|
|
175
|
+
telemetry — uses the same spectral and predictive machinery.
|
|
176
|
+
|
|
177
|
+
## Design
|
|
178
|
+
|
|
179
|
+
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.
|
|
183
|
+
|
|
184
|
+
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`.
|
|
187
|
+
|
|
188
|
+
`DSP::Waveform`, `DSP::PitchTrack`, `DSP::SpectralMoments`, `DSP::Biquad`, `DSP::Lpc::Model` and
|
|
189
|
+
`DSP::Formants::Peak` are `Data` value objects: frozen, compared by value, copied with `#with`.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
WTFPL — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Autocorrelation
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Unbiased-denominator-free autocorrelation r[k] = sum_i x[i] x[i + k], k = 0..order.
|
|
8
|
+
def call(frame, order)
|
|
9
|
+
raise ArgumentError, "order must be non-negative, got #{order}" if order.negative?
|
|
10
|
+
|
|
11
|
+
length = frame.length
|
|
12
|
+
|
|
13
|
+
Array.new(order + 1) do |lag|
|
|
14
|
+
total = 0.0
|
|
15
|
+
index = 0
|
|
16
|
+
limit = length - lag
|
|
17
|
+
while index < limit
|
|
18
|
+
total += frame[index] * frame[index + lag]
|
|
19
|
+
index += 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
total
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/dsprb/biquad.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
Biquad = Data.define(:b0, :b1, :b2, :a1, :a2) do
|
|
5
|
+
# Q of a Butterworth section: the maximally flat passband alignment.
|
|
6
|
+
BUTTERWORTH_Q = Math.sqrt(2.0) / 2.0
|
|
7
|
+
|
|
8
|
+
# Normalized cutoff bounds, keeping the design away from DC and Nyquist.
|
|
9
|
+
MINIMUM_NORMALIZED = 1e-4
|
|
10
|
+
MAXIMUM_NORMALIZED = 0.49
|
|
11
|
+
|
|
12
|
+
def self.lowpass(sample_rate:, cutoff:, q: BUTTERWORTH_Q) = design(sample_rate, cutoff, q, high: false)
|
|
13
|
+
|
|
14
|
+
def self.highpass(sample_rate:, cutoff:, q: BUTTERWORTH_Q) = design(sample_rate, cutoff, q, high: true)
|
|
15
|
+
|
|
16
|
+
def self.design(sample_rate, cutoff, q, high:)
|
|
17
|
+
omega = 2.0 * Math::PI * (cutoff.to_f / sample_rate).clamp(MINIMUM_NORMALIZED, MAXIMUM_NORMALIZED)
|
|
18
|
+
cosine = Math.cos(omega)
|
|
19
|
+
alpha = Math.sin(omega) / (2.0 * q)
|
|
20
|
+
scale = 1.0 + alpha
|
|
21
|
+
|
|
22
|
+
numerator = if high
|
|
23
|
+
[(1.0 + cosine) / 2.0, -(1.0 + cosine), (1.0 + cosine) / 2.0]
|
|
24
|
+
else
|
|
25
|
+
[(1.0 - cosine) / 2.0, 1.0 - cosine, (1.0 - cosine) / 2.0]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
new(
|
|
29
|
+
b0: numerator[0] / scale,
|
|
30
|
+
b1: numerator[1] / scale,
|
|
31
|
+
b2: numerator[2] / scale,
|
|
32
|
+
a1: (-2.0 * cosine) / scale,
|
|
33
|
+
a2: (1.0 - alpha) / scale
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_class_method :design
|
|
38
|
+
|
|
39
|
+
# Direct form II transposed, which keeps the state to two accumulators.
|
|
40
|
+
def call(samples)
|
|
41
|
+
first = 0.0
|
|
42
|
+
second = 0.0
|
|
43
|
+
out = Array.new(samples.length, 0.0)
|
|
44
|
+
|
|
45
|
+
index = 0
|
|
46
|
+
while index < samples.length
|
|
47
|
+
input = samples[index]
|
|
48
|
+
output = (b0 * input) + first
|
|
49
|
+
first = (b1 * input) - (a1 * output) + second
|
|
50
|
+
second = (b2 * input) - (a2 * output)
|
|
51
|
+
out[index] = output
|
|
52
|
+
index += 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cascading n identical sections yields a filter of order 2n.
|
|
59
|
+
def apply(samples, sections: 1)
|
|
60
|
+
raise ArgumentError, "sections must be positive, got #{sections}" unless sections.positive?
|
|
61
|
+
|
|
62
|
+
sections.times.reduce(samples) { |signal, _| call(signal) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/dsprb/curve.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Curve
|
|
5
|
+
module_function
|
|
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.
|
|
9
|
+
def resample(values, length)
|
|
10
|
+
raise ArgumentError, "length must be positive, got #{length}" unless length.positive?
|
|
11
|
+
return Array.new(length, 0.0) if values.empty?
|
|
12
|
+
return Array.new(length) { values.first.to_f } if values.length == 1 || length == 1
|
|
13
|
+
|
|
14
|
+
last = values.length - 1
|
|
15
|
+
span = length - 1
|
|
16
|
+
|
|
17
|
+
Array.new(length) do |index|
|
|
18
|
+
position = index.to_f * last / span
|
|
19
|
+
lower = position.floor
|
|
20
|
+
upper = [lower + 1, last].min
|
|
21
|
+
fraction = position - lower
|
|
22
|
+
(values[lower] * (1.0 - fraction)) + (values[upper] * fraction)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/dsprb/dct.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Dct
|
|
5
|
+
module_function
|
|
6
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
total
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
class Decimator
|
|
5
|
+
# An odd tap count keeps the FIR linear phase, so decimation adds no group delay skew.
|
|
6
|
+
DEFAULT_TAPS = 31
|
|
7
|
+
|
|
8
|
+
# Cutoff as a fraction of the decimated sample rate, leaving a transition margin below Nyquist.
|
|
9
|
+
CUTOFF_RATIO = 0.45
|
|
10
|
+
|
|
11
|
+
attr_reader :factor, :taps, :kernel
|
|
12
|
+
|
|
13
|
+
def self.factor_for(sample_rate, target) = [(sample_rate / target).floor, 1].max
|
|
14
|
+
|
|
15
|
+
def initialize(factor, taps: DEFAULT_TAPS)
|
|
16
|
+
@factor = Integer(factor)
|
|
17
|
+
@taps = Integer(taps)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, "factor must be at least one, got #{@factor}" unless @factor >= 1
|
|
20
|
+
raise ArgumentError, "taps must be a positive odd number, got #{@taps}" unless @taps.positive? && @taps.odd?
|
|
21
|
+
|
|
22
|
+
@middle = (@taps - 1) / 2
|
|
23
|
+
@kernel = build_kernel.freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(waveform)
|
|
28
|
+
return waveform if @factor == 1
|
|
29
|
+
|
|
30
|
+
Waveform.new(samples: filter(waveform.samples), sample_rate: waveform.sample_rate / @factor)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_kernel
|
|
36
|
+
cutoff = CUTOFF_RATIO / @factor
|
|
37
|
+
taper = Window.hamming(@taps)
|
|
38
|
+
|
|
39
|
+
raw = Array.new(@taps) do |index|
|
|
40
|
+
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
|
+
|
|
47
|
+
sinc * taper[index]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
total = raw.sum
|
|
51
|
+
raw.map { |value| value / total }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def filter(samples)
|
|
55
|
+
out = []
|
|
56
|
+
position = 0
|
|
57
|
+
while position < samples.length
|
|
58
|
+
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
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
out << total
|
|
67
|
+
position += @factor
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/dsprb/energy.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Energy
|
|
5
|
+
# Keeps the logarithm of a silent frame finite, placing digital silence near -120 dB.
|
|
6
|
+
FLOOR = 1e-12
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def frame_db(frame)
|
|
11
|
+
raise ArgumentError, "frame must not be empty" if frame.empty?
|
|
12
|
+
|
|
13
|
+
total = 0.0
|
|
14
|
+
index = 0
|
|
15
|
+
while index < frame.length
|
|
16
|
+
total += frame[index] * frame[index]
|
|
17
|
+
index += 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
to_db(total / frame.length)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def zero_crossing_rate(frame)
|
|
24
|
+
return 0.0 if frame.length < 2
|
|
25
|
+
|
|
26
|
+
crossings = 0
|
|
27
|
+
index = 1
|
|
28
|
+
while index < frame.length
|
|
29
|
+
crossings += 1 if frame[index].negative? != frame[index - 1].negative?
|
|
30
|
+
index += 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
crossings.to_f / (frame.length - 1)
|
|
34
|
+
end
|
|
35
|
+
|
|
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?
|
|
39
|
+
return [] if samples.length < window
|
|
40
|
+
|
|
41
|
+
running = 0.0
|
|
42
|
+
index = 0
|
|
43
|
+
while index < window
|
|
44
|
+
running += samples[index] * samples[index]
|
|
45
|
+
index += 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
out = [to_db(running / window)]
|
|
49
|
+
position = hop
|
|
50
|
+
while position + window <= samples.length
|
|
51
|
+
running = slide(samples, running, position, hop, window)
|
|
52
|
+
out << to_db([running, 0.0].max / window)
|
|
53
|
+
position += hop
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def slide(samples, running, position, hop, window)
|
|
60
|
+
index = position - hop
|
|
61
|
+
while index < position
|
|
62
|
+
running -= samples[index] * samples[index]
|
|
63
|
+
running += samples[index + window] * samples[index + window]
|
|
64
|
+
index += 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
running
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_db(power) = 10.0 * Math.log10(power + FLOOR)
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/dsprb/errors.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
class Formants
|
|
5
|
+
Peak = Data.define(:frequency, :amplitude)
|
|
6
|
+
|
|
7
|
+
# Conventional ceiling for adult speech; the first four formants fall below it.
|
|
8
|
+
DEFAULT_MAXIMUM_HZ = 5500.0
|
|
9
|
+
DEFAULT_COUNT = 4
|
|
10
|
+
|
|
11
|
+
# Envelope samples taken over [0, Nyquist].
|
|
12
|
+
DEFAULT_RESOLUTION = 512
|
|
13
|
+
|
|
14
|
+
# One resonance can split into twin envelope peaks; peaks closer than this are merged.
|
|
15
|
+
DEFAULT_SEPARATION_HZ = 250.0
|
|
16
|
+
|
|
17
|
+
# Peaks pinned against the band edges are artifacts of the truncated envelope.
|
|
18
|
+
DEFAULT_EDGE_MARGIN_HZ = 150.0
|
|
19
|
+
|
|
20
|
+
# Below this the frame is too short to fit the predictor reliably.
|
|
21
|
+
MINIMUM_SAMPLES = 40
|
|
22
|
+
|
|
23
|
+
ENERGY_FLOOR = 1e-10
|
|
24
|
+
LOG_FLOOR = 1e-18
|
|
25
|
+
|
|
26
|
+
attr_reader :maximum_frequency, :count, :resolution, :separation, :edge_margin
|
|
27
|
+
|
|
28
|
+
def initialize(
|
|
29
|
+
maximum_frequency: DEFAULT_MAXIMUM_HZ,
|
|
30
|
+
count: DEFAULT_COUNT,
|
|
31
|
+
resolution: DEFAULT_RESOLUTION,
|
|
32
|
+
separation: DEFAULT_SEPARATION_HZ,
|
|
33
|
+
edge_margin: DEFAULT_EDGE_MARGIN_HZ
|
|
34
|
+
)
|
|
35
|
+
@maximum_frequency = Float(maximum_frequency)
|
|
36
|
+
@count = Integer(count)
|
|
37
|
+
@resolution = Integer(resolution)
|
|
38
|
+
@separation = Float(separation)
|
|
39
|
+
@edge_margin = Float(edge_margin)
|
|
40
|
+
|
|
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
|
|
43
|
+
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call(waveform) = peaks(waveform).map(&:frequency)
|
|
48
|
+
|
|
49
|
+
def peaks(waveform)
|
|
50
|
+
reduced = reduce(waveform)
|
|
51
|
+
return [] if reduced.length < MINIMUM_SAMPLES
|
|
52
|
+
|
|
53
|
+
model = fit(reduced)
|
|
54
|
+
return [] if model.nil?
|
|
55
|
+
|
|
56
|
+
merge(extrema(model.envelope(points: @resolution), reduced.sample_rate)).first(@count)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def reduce(waveform)
|
|
62
|
+
Decimator.new(Decimator.factor_for(waveform.sample_rate, 2.0 * @maximum_frequency)).call(waveform)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fit(waveform)
|
|
66
|
+
order = (2 * @count) + 2
|
|
67
|
+
windowed = Window.apply(waveform.samples, Window.hamming(waveform.length))
|
|
68
|
+
correlation = Autocorrelation.call(Framing.preemphasis(windowed), order)
|
|
69
|
+
return nil if correlation[0] <= ENERGY_FLOOR
|
|
70
|
+
|
|
71
|
+
Lpc.levinson(correlation, order)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extrema(envelope, sample_rate)
|
|
75
|
+
nyquist = sample_rate / 2.0
|
|
76
|
+
span = envelope.length - 1
|
|
77
|
+
found = []
|
|
78
|
+
|
|
79
|
+
index = 1
|
|
80
|
+
while index < span
|
|
81
|
+
found << refine(envelope, index, nyquist, span) if rising?(envelope, index)
|
|
82
|
+
index += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
found.compact
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rising?(envelope, index)
|
|
89
|
+
envelope[index] > envelope[index - 1] && envelope[index] >= envelope[index + 1]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def refine(envelope, index, nyquist, span)
|
|
93
|
+
previous = Math.log(envelope[index - 1] + LOG_FLOOR)
|
|
94
|
+
current = Math.log(envelope[index] + LOG_FLOOR)
|
|
95
|
+
following = Math.log(envelope[index + 1] + LOG_FLOOR)
|
|
96
|
+
frequency = nyquist * (index + Parabolic.vertex_offset(previous, current, following)) / span
|
|
97
|
+
return nil unless frequency > @edge_margin && frequency < nyquist - @edge_margin
|
|
98
|
+
|
|
99
|
+
Peak.new(frequency: frequency, amplitude: current)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def merge(found)
|
|
103
|
+
found.sort_by(&:frequency).each_with_object([]) do |peak, merged|
|
|
104
|
+
if merged.any? && (peak.frequency - merged.last.frequency) < @separation
|
|
105
|
+
merged[-1] = peak if peak.amplitude > merged.last.amplitude
|
|
106
|
+
else
|
|
107
|
+
merged << peak
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|