spcore 0.2.0 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|