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 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ Error = Class.new(StandardError)
5
+
6
+ FormatError = Class.new(Error)
7
+
8
+ UnknownStrategyError = Class.new(Error)
9
+ end
@@ -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