spcore 0.2.0 → 0.2.1
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.
- data/ChangeLog.rdoc +5 -1
- data/lib/spcore.rb +9 -6
- data/lib/spcore/analysis/calculus.rb +38 -0
- data/lib/spcore/analysis/features.rb +186 -0
- data/lib/spcore/analysis/frequency_domain.rb +191 -0
- data/lib/spcore/analysis/{correlation.rb → statistics.rb} +41 -18
- data/lib/spcore/core/delay_line.rb +1 -1
- data/lib/spcore/filters/fir/dual_sinc_filter.rb +1 -1
- data/lib/spcore/filters/fir/sinc_filter.rb +1 -1
- data/lib/spcore/generation/comb_filter.rb +65 -0
- data/lib/spcore/{core → generation}/oscillator.rb +1 -1
- data/lib/spcore/{util → generation}/signal_generator.rb +1 -1
- data/lib/spcore/util/envelope_detector.rb +1 -1
- data/lib/spcore/util/gain.rb +9 -167
- data/lib/spcore/util/plotter.rb +1 -1
- data/lib/spcore/{analysis → util}/signal.rb +116 -127
- data/lib/spcore/version.rb +1 -1
- data/spcore.gemspec +2 -0
- data/spec/analysis/calculus_spec.rb +54 -0
- data/spec/analysis/features_spec.rb +106 -0
- data/spec/analysis/frequency_domain_spec.rb +147 -0
- data/spec/analysis/piano_C4.wav +0 -0
- data/spec/analysis/statistics_spec.rb +61 -0
- data/spec/analysis/trumpet_B4.wav +0 -0
- data/spec/generation/comb_filter_spec.rb +37 -0
- data/spec/{core → generation}/oscillator_spec.rb +0 -0
- data/spec/{util → generation}/signal_generator_spec.rb +0 -0
- data/spec/interpolation/interpolation_spec.rb +0 -2
- data/spec/{analysis → util}/signal_spec.rb +1 -35
- metadata +64 -22
- data/lib/spcore/analysis/envelope.rb +0 -76
- data/lib/spcore/analysis/extrema.rb +0 -55
- data/spec/analysis/correlation_spec.rb +0 -28
- data/spec/analysis/envelope_spec.rb +0 -50
- data/spec/analysis/extrema_spec.rb +0 -42
data/ChangeLog.rdoc
CHANGED
@@ -54,4 +54,8 @@ Add Signal#keep_frequences, Signal#remove_frequencies, and Plotter#plot_signals.
|
|
54
54
|
|
55
55
|
Add instance methods to Signal class: #duration, #normalize, #derivative, #lowpass, #highpass, #bandpass, #bandstop, #plot_1d, and #plot_2d.
|
56
56
|
Add Correlation class to find similarity between signals (used to find a feature in an image).
|
57
|
-
Make envelopes smoother with polynomial upsampling. In Signal#envelope and Signal, always return a Signal object (so also remove make_signal flag).
|
57
|
+
Make envelopes smoother with polynomial upsampling. In Signal#envelope and Signal, always return a Signal object (so also remove make_signal flag).
|
58
|
+
|
59
|
+
=== 0.2.1 / 2013-07-03
|
60
|
+
|
61
|
+
Update for compatibility with hashmake-0.2.0.
|
data/lib/spcore.rb
CHANGED
@@ -4,7 +4,6 @@ require 'spcore/version'
|
|
4
4
|
require 'spcore/core/circular_buffer'
|
5
5
|
require 'spcore/core/constants'
|
6
6
|
require 'spcore/core/delay_line'
|
7
|
-
require 'spcore/core/oscillator'
|
8
7
|
|
9
8
|
require 'spcore/windows/bartlett_hann_window'
|
10
9
|
require 'spcore/windows/bartlett_window'
|
@@ -41,15 +40,19 @@ require 'spcore/resampling/discrete_resampling'
|
|
41
40
|
require 'spcore/resampling/polynomial_resampling'
|
42
41
|
require 'spcore/resampling/hybrid_resampling'
|
43
42
|
|
44
|
-
require 'spcore/analysis/
|
45
|
-
require 'spcore/analysis/
|
46
|
-
require 'spcore/analysis/
|
47
|
-
require 'spcore/analysis/
|
43
|
+
require 'spcore/analysis/calculus'
|
44
|
+
require 'spcore/analysis/features'
|
45
|
+
require 'spcore/analysis/statistics'
|
46
|
+
require 'spcore/analysis/frequency_domain'
|
48
47
|
|
49
48
|
require 'spcore/util/gain'
|
50
49
|
require 'spcore/util/limiters'
|
51
50
|
require 'spcore/util/plotter'
|
52
51
|
require 'spcore/util/saturation'
|
53
52
|
require 'spcore/util/scale'
|
54
|
-
require 'spcore/util/
|
53
|
+
require 'spcore/util/signal'
|
55
54
|
require 'spcore/util/envelope_detector'
|
55
|
+
|
56
|
+
require 'spcore/generation/oscillator'
|
57
|
+
require 'spcore/generation/signal_generator'
|
58
|
+
require 'spcore/generation/comb_filter'
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module SPCore
|
2
|
+
# Calculus analysis methods.
|
3
|
+
class Calculus
|
4
|
+
# Differentiates the given values.
|
5
|
+
#
|
6
|
+
# @param [Array] values The input value series.
|
7
|
+
# @param [Numeric] dt The time differential (i.e. the sample period)
|
8
|
+
def self.derivative values, dt
|
9
|
+
raise "values.size is <= 2" if values.size <= 2
|
10
|
+
|
11
|
+
derivative = Array.new(values.size)
|
12
|
+
|
13
|
+
for i in 1...values.count
|
14
|
+
derivative[i] = (values[i] - values[i-1]) / dt
|
15
|
+
end
|
16
|
+
|
17
|
+
derivative[0] = derivative[1]
|
18
|
+
return derivative
|
19
|
+
end
|
20
|
+
|
21
|
+
# Integrates the given values.
|
22
|
+
#
|
23
|
+
# @param [Array] values The input value series.
|
24
|
+
# @param [Numeric] dt The time differential (i.e. the sample period)
|
25
|
+
def self.integral values, dt
|
26
|
+
raise "values.size is <= 2" if values.size <= 2
|
27
|
+
|
28
|
+
integral = Array.new(values.size)
|
29
|
+
|
30
|
+
integral[0] = values[0] * dt
|
31
|
+
for i in 1...values.count
|
32
|
+
integral[i] = (values[i] * dt) + integral[i-1]
|
33
|
+
end
|
34
|
+
|
35
|
+
return integral
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
module SPCore
|
2
|
+
# Features analysis methods.
|
3
|
+
class Features
|
4
|
+
# Returns all minima and maxima (including positive minima and negative maxima).
|
5
|
+
def self.extrema samples
|
6
|
+
remove_inner = false
|
7
|
+
self.extrema_hash(samples, remove_inner)[:extrema]
|
8
|
+
end
|
9
|
+
|
10
|
+
# Returns outer minima and maxima (excludes positive minima and negative maxima).
|
11
|
+
def self.outer_extrema samples
|
12
|
+
remove_inner = true
|
13
|
+
self.extrema_hash(samples, remove_inner)[:extrema]
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns all minima (including positive minima).
|
17
|
+
def self.minima samples
|
18
|
+
remove_inner = false
|
19
|
+
self.extrema_hash(samples, remove_inner)[:minima]
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns all minima (excludes positive minima).
|
23
|
+
def self.negative_minima samples
|
24
|
+
remove_inner = true
|
25
|
+
self.extrema_hash(samples, remove_inner)[:minima]
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns maxima (includes negative maxima).
|
29
|
+
def self.maxima samples
|
30
|
+
remove_inner = false
|
31
|
+
self.extrema_hash(samples, remove_inner)[:maxima]
|
32
|
+
end
|
33
|
+
|
34
|
+
# Returns maxima (excludes negative maxima).
|
35
|
+
def self.positive_maxima samples
|
36
|
+
remove_inner = true
|
37
|
+
self.extrema_hash(samples, remove_inner)[:maxima]
|
38
|
+
end
|
39
|
+
|
40
|
+
# return the n greatest values of the given array of values.
|
41
|
+
def self.top_n values, n
|
42
|
+
top_n = []
|
43
|
+
values.each do |value|
|
44
|
+
if top_n.count < n
|
45
|
+
top_n.push value
|
46
|
+
else
|
47
|
+
smaller = top_n.select {|x| x < value}
|
48
|
+
if smaller.any?
|
49
|
+
top_n.delete smaller.min
|
50
|
+
top_n.push value
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
return top_n.sort
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.outline samples
|
58
|
+
starting_outline = make_starting_outline samples
|
59
|
+
filled_in_outline = fill_in_starting_outline starting_outline, samples.count
|
60
|
+
dropsample_factor = (samples.count / starting_outline.count).to_i
|
61
|
+
return dropsample_filled_in_outline filled_in_outline, dropsample_factor
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.envelope samples
|
65
|
+
outline = self.outline samples
|
66
|
+
upsample_factor = samples.count / outline.count.to_f
|
67
|
+
return PolynomialResampling.upsample(outline, upsample_factor)
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def self.make_starting_outline samples
|
73
|
+
starting_outline = self.outer_extrema(samples)
|
74
|
+
|
75
|
+
# add in first and last samples so the envelope follows entire signal
|
76
|
+
starting_outline[0] = samples[0].abs
|
77
|
+
starting_outline[samples.count - 1] = samples[samples.count - 1].abs
|
78
|
+
|
79
|
+
# the envelope is only concerned with absolute values
|
80
|
+
starting_outline.keys.each do |key|
|
81
|
+
starting_outline[key] = starting_outline[key].abs
|
82
|
+
end
|
83
|
+
|
84
|
+
return starting_outline
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.fill_in_starting_outline starting_outline, tgt_count
|
88
|
+
# the extrema we have now are probably not spaced evenly. Upsampling at
|
89
|
+
# this point would lead to a time-distorted signal. So the next step is to
|
90
|
+
# interpolate between all the extrema to make a crude but properly sampled
|
91
|
+
# envelope.
|
92
|
+
|
93
|
+
proper_outline = Array.new(tgt_count, 0)
|
94
|
+
indices = starting_outline.keys.sort
|
95
|
+
|
96
|
+
for i in 1...indices.count
|
97
|
+
l_idx = indices[i-1]
|
98
|
+
r_idx = indices[i]
|
99
|
+
|
100
|
+
l_val = starting_outline[l_idx]
|
101
|
+
r_val = starting_outline[r_idx]
|
102
|
+
|
103
|
+
proper_outline[l_idx] = l_val
|
104
|
+
proper_outline[r_idx] = r_val
|
105
|
+
|
106
|
+
idx_span = r_idx - l_idx
|
107
|
+
|
108
|
+
for j in (l_idx + 1)...(r_idx)
|
109
|
+
x = (j - l_idx).to_f / idx_span
|
110
|
+
y = Interpolation.linear l_val, r_val, x
|
111
|
+
proper_outline[j] = y
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
return proper_outline
|
116
|
+
end
|
117
|
+
|
118
|
+
# This properly spaces samples in the filled in outline, so as to avoid time
|
119
|
+
# distortion after upsampling.
|
120
|
+
def self.dropsample_filled_in_outline filled_in_outline, dropsample_factor
|
121
|
+
dropsampled_outline = []
|
122
|
+
|
123
|
+
(0...filled_in_outline.count).step(dropsample_factor) do |n|
|
124
|
+
dropsampled_outline.push filled_in_outline[n]
|
125
|
+
end
|
126
|
+
|
127
|
+
return dropsampled_outline
|
128
|
+
end
|
129
|
+
|
130
|
+
# Finds extrema (minima and maxima), return :extrema, :minima, and :maxima
|
131
|
+
# seperately in a hash.
|
132
|
+
def self.extrema_hash samples, remove_inner = false
|
133
|
+
minima = {}
|
134
|
+
maxima = {}
|
135
|
+
|
136
|
+
global_min_idx = 0
|
137
|
+
global_min_val = samples[0]
|
138
|
+
global_max_idx = 0
|
139
|
+
global_max_val = samples[0]
|
140
|
+
|
141
|
+
diffs = []
|
142
|
+
for i in (1...samples.count)
|
143
|
+
diffs.push(samples[i] - samples[i-1])
|
144
|
+
|
145
|
+
if samples[i] < global_min_val
|
146
|
+
global_min_idx = i
|
147
|
+
global_min_val = samples[i]
|
148
|
+
end
|
149
|
+
|
150
|
+
if samples[i] > global_max_val
|
151
|
+
global_max_idx = i
|
152
|
+
global_max_val = samples[i]
|
153
|
+
end
|
154
|
+
end
|
155
|
+
minima[global_min_idx] = global_min_val
|
156
|
+
maxima[global_max_idx] = global_max_val
|
157
|
+
|
158
|
+
is_positive = diffs.first > 0.0 # starting off with positive difference?
|
159
|
+
|
160
|
+
# at zero crossings there is a local maxima/minima
|
161
|
+
for i in (1...diffs.count)
|
162
|
+
if is_positive
|
163
|
+
# at positive-to-negative transition there is a local maxima
|
164
|
+
if diffs[i] <= 0.0
|
165
|
+
maxima[i] = samples[i]
|
166
|
+
is_positive = false
|
167
|
+
end
|
168
|
+
else
|
169
|
+
# at negative-to-positive transition there is a local minima
|
170
|
+
if diffs[i] > 0.0
|
171
|
+
minima[i] = samples[i]
|
172
|
+
is_positive = true
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
if remove_inner
|
178
|
+
minima.keep_if {|idx,val| val <= 0 }
|
179
|
+
maxima.keep_if {|idx,val| val >= 0 }
|
180
|
+
end
|
181
|
+
|
182
|
+
return :minima => minima, :maxima => maxima, :extrema => minima.merge(maxima)
|
183
|
+
end
|
184
|
+
|
185
|
+
end
|
186
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
module SPCore
|
2
|
+
# Frequency domain analysis class. On instantiation a forware FFT is performed
|
3
|
+
# on the given time series data, and the results are stored in full and half form.
|
4
|
+
# The half-FFT form cuts out the latter half of the FFT results. Also, for the
|
5
|
+
# half-FFT the complex values will be converted to magnitude (linear or decibel)
|
6
|
+
# if specified in :fft_format (see FFT_FORMATS for valid values).
|
7
|
+
class FrequencyDomain
|
8
|
+
include Hashmake::HashMakeable
|
9
|
+
|
10
|
+
FFT_COMPLEX_VALUED = :complexValued
|
11
|
+
FFT_MAGNITUDE_LINEAR = :magnitudeLinear
|
12
|
+
FFT_MAGNITUDE_DECIBEL = :magnitudeDecibel
|
13
|
+
|
14
|
+
# valid values to give for the :fft_format key.
|
15
|
+
FFT_FORMATS = [
|
16
|
+
FFT_COMPLEX_VALUED,
|
17
|
+
FFT_MAGNITUDE_LINEAR,
|
18
|
+
FFT_MAGNITUDE_DECIBEL
|
19
|
+
]
|
20
|
+
|
21
|
+
# define how the class is to be instantiated by hash.
|
22
|
+
ARG_SPECS = {
|
23
|
+
:time_data => arg_spec_array(:reqd => true, :type => Numeric),
|
24
|
+
:sample_rate => arg_spec(:reqd => true, :type => Numeric, :validator => ->(a){ a > 0 }),
|
25
|
+
:fft_format => arg_spec(:reqd => false, :type => Symbol, :default => FFT_MAGNITUDE_DECIBEL, :validator => ->(a){FFT_FORMATS.include?(a)})
|
26
|
+
}
|
27
|
+
|
28
|
+
attr_reader :time_data, :sample_rate, :fft_format, :fft_full, :fft_half
|
29
|
+
|
30
|
+
def initialize args
|
31
|
+
hash_make args, FrequencyDomain::ARG_SPECS
|
32
|
+
@fft_full = FFT.forward @time_data
|
33
|
+
@fft_half = @fft_full[0...(@fft_full.size / 2)]
|
34
|
+
|
35
|
+
case(@fft_format)
|
36
|
+
when FFT_MAGNITUDE_LINEAR
|
37
|
+
@fft_half = @fft_half.map {|x| x.magnitude }
|
38
|
+
when FFT_MAGNITUDE_DECIBEL
|
39
|
+
@fft_half = @fft_half.map {|x| Gain.linear_to_db x.magnitude } # in decibels
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Convert an FFT output index to the corresponding frequency bin
|
44
|
+
def idx_to_freq(idx)
|
45
|
+
return (idx * @sample_rate.to_f) / @fft_full.size
|
46
|
+
end
|
47
|
+
|
48
|
+
# Convert an FFT frequency bin to the corresponding FFT output index
|
49
|
+
def freq_to_idx(freq)
|
50
|
+
return (freq * @fft_full.size) / @sample_rate.to_f
|
51
|
+
end
|
52
|
+
|
53
|
+
# Find frequency peak values.
|
54
|
+
def peaks
|
55
|
+
# map positive maxima to indices
|
56
|
+
positive_maxima = Features.positive_maxima(@fft_half)
|
57
|
+
|
58
|
+
freq_peaks = {}
|
59
|
+
positive_maxima.keys.sort.each do |idx|
|
60
|
+
freq = idx_to_freq(idx)
|
61
|
+
freq_peaks[freq] = positive_maxima[idx]
|
62
|
+
end
|
63
|
+
|
64
|
+
return freq_peaks
|
65
|
+
end
|
66
|
+
|
67
|
+
GCD = :gcd
|
68
|
+
WINDOW = :window
|
69
|
+
HARMONIC_SERIES_APPROACHES = [ GCD, WINDOW ]
|
70
|
+
|
71
|
+
# Find the strongest harmonic series among the given peak data.
|
72
|
+
def harmonic_series opts = {}
|
73
|
+
defaults = { :n_peaks => 8, :min_freq => 40.0, :approach => WINDOW }
|
74
|
+
opts = defaults.merge(opts)
|
75
|
+
|
76
|
+
n_peaks = opts[:n_peaks]
|
77
|
+
min_freq = opts[:min_freq]
|
78
|
+
approach = opts[:approach]
|
79
|
+
|
80
|
+
raise ArgumentError, "n_peaks is < 1" if n_peaks < 1
|
81
|
+
peaks = self.peaks
|
82
|
+
|
83
|
+
if peaks.empty?
|
84
|
+
return []
|
85
|
+
end
|
86
|
+
|
87
|
+
max_freq = peaks.keys.max
|
88
|
+
max_idx = freq_to_idx(max_freq)
|
89
|
+
|
90
|
+
sorted_pairs = peaks.sort_by {|f,m| m}
|
91
|
+
top_n_pairs = sorted_pairs.reverse[0, n_peaks]
|
92
|
+
|
93
|
+
candidate_series = []
|
94
|
+
|
95
|
+
case approach
|
96
|
+
when GCD
|
97
|
+
for n in 1..n_peaks
|
98
|
+
combinations = top_n_pairs.combination(n).to_a
|
99
|
+
combinations.each do |combination|
|
100
|
+
freq_indices = combination.map {|pair| freq_to_idx(pair[0]) }
|
101
|
+
fund_idx = multi_gcd freq_indices
|
102
|
+
|
103
|
+
if fund_idx >= freq_to_idx(min_freq)
|
104
|
+
series = []
|
105
|
+
idx = fund_idx
|
106
|
+
while idx <= max_idx
|
107
|
+
freq = idx_to_freq(idx)
|
108
|
+
#if peaks.has_key? freq
|
109
|
+
series.push freq
|
110
|
+
#end
|
111
|
+
idx += fund_idx
|
112
|
+
end
|
113
|
+
candidate_series.push series
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
when WINDOW
|
118
|
+
# look for a harmonic series
|
119
|
+
top_n_pairs.each do |pair|
|
120
|
+
f_base = pair[0]
|
121
|
+
|
122
|
+
min_idx_base = freq_to_idx(f_base) - 0.5
|
123
|
+
max_idx_base = min_idx_base + 1.0
|
124
|
+
|
125
|
+
harmonic_series = [ f_base ]
|
126
|
+
target = 2 * f_base
|
127
|
+
min_idx = 2 * min_idx_base
|
128
|
+
max_idx = 2 * max_idx_base
|
129
|
+
|
130
|
+
while target < max_freq
|
131
|
+
f_l = idx_to_freq(min_idx.floor)
|
132
|
+
f_h = idx_to_freq(max_idx.ceil)
|
133
|
+
window = f_l..f_h
|
134
|
+
candidates = peaks.select {|actual,magn| window.include?(actual) }
|
135
|
+
|
136
|
+
if candidates.any?
|
137
|
+
min = candidates.min_by {|actual,magn| (actual - target).abs }
|
138
|
+
harmonic_series.push min[0]
|
139
|
+
else
|
140
|
+
break
|
141
|
+
end
|
142
|
+
|
143
|
+
target += f_base
|
144
|
+
min_idx += min_idx_base
|
145
|
+
max_idx += max_idx_base
|
146
|
+
end
|
147
|
+
|
148
|
+
candidate_series.push harmonic_series
|
149
|
+
end
|
150
|
+
else
|
151
|
+
raise ArgumentError, "#{approach} approach is not supported"
|
152
|
+
end
|
153
|
+
|
154
|
+
strongest_series = candidate_series.max_by do |harmonic_series|
|
155
|
+
sum = 0
|
156
|
+
harmonic_series.each do |f|
|
157
|
+
if peaks.has_key?(f)
|
158
|
+
sum += peaks[f]**2
|
159
|
+
else
|
160
|
+
m = @fft_half[freq_to_idx(f)].magnitude
|
161
|
+
sum += m**2
|
162
|
+
end
|
163
|
+
end
|
164
|
+
sum
|
165
|
+
end
|
166
|
+
|
167
|
+
return strongest_series
|
168
|
+
end
|
169
|
+
|
170
|
+
private
|
171
|
+
|
172
|
+
def gcd a,b
|
173
|
+
if b == 0
|
174
|
+
return a
|
175
|
+
else
|
176
|
+
return gcd(b, a % b)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def multi_gcd nums
|
181
|
+
if nums.count == 1
|
182
|
+
return nums[0]
|
183
|
+
elsif nums.count == 2
|
184
|
+
return gcd nums[0], nums[1]
|
185
|
+
else
|
186
|
+
return multi_gcd [gcd(nums[0], nums[1])] + nums[2..-1]
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|
191
|
+
end
|