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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Fourier
|
|
5
|
+
class Radix2
|
|
6
|
+
attr_reader :size
|
|
7
|
+
|
|
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
|
+
|
|
12
|
+
@stages = build_stages(@size)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(real, imaginary)
|
|
17
|
+
assert_size!(real, imaginary)
|
|
18
|
+
permute(real, imaginary)
|
|
19
|
+
|
|
20
|
+
span = 2
|
|
21
|
+
while span <= @size
|
|
22
|
+
cosines, sines = @stages[span]
|
|
23
|
+
half = span / 2
|
|
24
|
+
block = 0
|
|
25
|
+
while block < @size
|
|
26
|
+
index = 0
|
|
27
|
+
while index < half
|
|
28
|
+
butterfly(real, imaginary, block + index, half, cosines[index], sines[index])
|
|
29
|
+
index += 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
block += span
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
span <<= 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
[real, imaginary]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def power_of_two?(value) = value.positive? && (value & (value - 1)).zero?
|
|
44
|
+
|
|
45
|
+
def build_stages(size)
|
|
46
|
+
stages = {}
|
|
47
|
+
span = 2
|
|
48
|
+
while span <= size
|
|
49
|
+
half = span / 2
|
|
50
|
+
cosines = Array.new(half)
|
|
51
|
+
sines = Array.new(half)
|
|
52
|
+
index = 0
|
|
53
|
+
while index < half
|
|
54
|
+
angle = -2.0 * Math::PI * index / span
|
|
55
|
+
cosines[index] = Math.cos(angle)
|
|
56
|
+
sines[index] = Math.sin(angle)
|
|
57
|
+
index += 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
stages[span] = [cosines.freeze, sines.freeze]
|
|
61
|
+
span <<= 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
stages.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assert_size!(real, imaginary)
|
|
68
|
+
return if real.length == @size && imaginary.length == @size
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "expected two buffers of length #{@size}, got #{real.length} and #{imaginary.length}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def permute(real, imaginary)
|
|
74
|
+
target = 0
|
|
75
|
+
source = 1
|
|
76
|
+
while source < @size
|
|
77
|
+
bit = @size >> 1
|
|
78
|
+
while (target & bit) != 0
|
|
79
|
+
target ^= bit
|
|
80
|
+
bit >>= 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
target |= bit
|
|
84
|
+
if source < target
|
|
85
|
+
real[source], real[target] = real[target], real[source]
|
|
86
|
+
imaginary[source], imaginary[target] = imaginary[target], imaginary[source]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
source += 1
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def butterfly(real, imaginary, top, half, cosine, sine)
|
|
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
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Framing
|
|
5
|
+
# First-order high-pass that flattens the -6 dB/octave tilt of the glottal source.
|
|
6
|
+
DEFAULT_PREEMPHASIS = 0.97
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def frames(samples, window:, hop:)
|
|
11
|
+
raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
|
|
12
|
+
raise ArgumentError, "hop must be positive, got #{hop}" unless hop.positive?
|
|
13
|
+
|
|
14
|
+
out = []
|
|
15
|
+
position = 0
|
|
16
|
+
while position + window <= samples.length
|
|
17
|
+
out << samples[position, window]
|
|
18
|
+
position += hop
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
out
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def frame_count(length, window:, hop:)
|
|
25
|
+
return 0 if length < window
|
|
26
|
+
|
|
27
|
+
((length - window) / hop) + 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def preemphasis(samples, coefficient: DEFAULT_PREEMPHASIS)
|
|
31
|
+
return [] if samples.empty?
|
|
32
|
+
|
|
33
|
+
out = Array.new(samples.length)
|
|
34
|
+
out[0] = samples[0].to_f
|
|
35
|
+
index = 1
|
|
36
|
+
while index < samples.length
|
|
37
|
+
out[index] = samples[index] - (coefficient * samples[index - 1])
|
|
38
|
+
index += 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
out
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/dsprb/lpc.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Lpc
|
|
5
|
+
# Below this the all-pole denominator is treated as a pole on the unit circle.
|
|
6
|
+
DENOMINATOR_FLOOR = 1e-18
|
|
7
|
+
|
|
8
|
+
UNBOUNDED_MAGNITUDE = 1e9
|
|
9
|
+
|
|
10
|
+
Model = Data.define(:coefficients, :error) do
|
|
11
|
+
def order = coefficients.length - 1
|
|
12
|
+
|
|
13
|
+
# Magnitude of the all-pole transfer function 1 / |A(e^{i w})|^2 sampled
|
|
14
|
+
# over [0, pi], the spectral envelope the predictor implies.
|
|
15
|
+
def envelope(points:)
|
|
16
|
+
raise ArgumentError, "points must be at least two, got #{points}" unless points >= 2
|
|
17
|
+
|
|
18
|
+
span = points - 1
|
|
19
|
+
|
|
20
|
+
Array.new(points) do |index|
|
|
21
|
+
real, imaginary = response(Math::PI * index / span)
|
|
22
|
+
denominator = (real * real) + (imaginary * imaginary)
|
|
23
|
+
denominator < DENOMINATOR_FLOOR ? UNBOUNDED_MAGNITUDE : 1.0 / denominator
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def response(omega)
|
|
30
|
+
real = 0.0
|
|
31
|
+
imaginary = 0.0
|
|
32
|
+
index = 0
|
|
33
|
+
while index < coefficients.length
|
|
34
|
+
angle = -omega * index
|
|
35
|
+
real += coefficients[index] * Math.cos(angle)
|
|
36
|
+
imaginary += coefficients[index] * Math.sin(angle)
|
|
37
|
+
index += 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
[real, imaginary]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def solve(frame, order:) = levinson(Autocorrelation.call(frame, order), order)
|
|
47
|
+
|
|
48
|
+
# Levinson-Durbin recursion: solves the Yule-Walker system in O(order^2),
|
|
49
|
+
# stopping early when a reflection coefficient leaves the unit circle.
|
|
50
|
+
def levinson(autocorrelation, order)
|
|
51
|
+
coefficients = Array.new(order + 1, 0.0)
|
|
52
|
+
coefficients[0] = 1.0
|
|
53
|
+
error = autocorrelation[0].to_f
|
|
54
|
+
return Model.new(coefficients: coefficients, error: 0.0) unless error.positive?
|
|
55
|
+
|
|
56
|
+
(1..order).each do |step|
|
|
57
|
+
total = autocorrelation[step]
|
|
58
|
+
(1...step).each { |index| total += coefficients[index] * autocorrelation[step - index] }
|
|
59
|
+
reflection = -total / error
|
|
60
|
+
return Model.new(coefficients: coefficients, error: error) if reflection.abs >= 1.0
|
|
61
|
+
|
|
62
|
+
coefficients = advance(coefficients, step, reflection)
|
|
63
|
+
error *= 1.0 - (reflection * reflection)
|
|
64
|
+
return Model.new(coefficients: coefficients, error: error) unless error.positive?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Model.new(coefficients: coefficients, error: error)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def advance(coefficients, step, reflection)
|
|
71
|
+
updated = coefficients.dup
|
|
72
|
+
(1...step).each { |index| updated[index] = coefficients[index] + (reflection * coefficients[step - index]) }
|
|
73
|
+
updated[step] = reflection
|
|
74
|
+
updated
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
class MelFilterbank
|
|
5
|
+
Band = Data.define(:offset, :weights) do
|
|
6
|
+
def length = weights.length
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
DEFAULT_FILTERS = 26
|
|
10
|
+
DEFAULT_LOW_HZ = 50.0
|
|
11
|
+
|
|
12
|
+
# Keeps the logarithm of a band that collected no energy finite.
|
|
13
|
+
ENERGY_FLOOR = 1e-12
|
|
14
|
+
|
|
15
|
+
attr_reader :sample_rate, :size, :filters, :low, :high, :warp, :bands
|
|
16
|
+
|
|
17
|
+
# warp rescales the band centers for vocal tract length normalization:
|
|
18
|
+
# values above 1 shift the bank upwards, suiting shorter vocal tracts.
|
|
19
|
+
def initialize(sample_rate:, size:, filters: DEFAULT_FILTERS, low: DEFAULT_LOW_HZ, high: nil, warp: 1.0)
|
|
20
|
+
@sample_rate = Float(sample_rate)
|
|
21
|
+
@size = Integer(size)
|
|
22
|
+
@filters = Integer(filters)
|
|
23
|
+
@low = Float(low)
|
|
24
|
+
@high = Float(high || (@sample_rate / 2.0))
|
|
25
|
+
@warp = Float(warp)
|
|
26
|
+
|
|
27
|
+
validate!
|
|
28
|
+
@bands = build_bands.freeze
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def energies(power)
|
|
33
|
+
@bands.map do |band|
|
|
34
|
+
weights = band.weights
|
|
35
|
+
offset = band.offset
|
|
36
|
+
total = 0.0
|
|
37
|
+
index = 0
|
|
38
|
+
while index < weights.length
|
|
39
|
+
total += weights[index] * power[offset + index]
|
|
40
|
+
index += 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
total
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def log_energies(power) = energies(power).map { |value| Math.log([value, ENERGY_FLOOR].max) }
|
|
48
|
+
|
|
49
|
+
def center_frequencies = @centers
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate!
|
|
54
|
+
raise ArgumentError, "filters must be positive, got #{@filters}" unless @filters.positive?
|
|
55
|
+
raise ArgumentError, "warp must be positive, got #{@warp}" unless @warp.positive?
|
|
56
|
+
raise ArgumentError, "low must be below high, got #{@low} and #{@high}" unless @low < @high
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_bands
|
|
60
|
+
edges = mel_spaced_edges
|
|
61
|
+
@centers = edges[1..-2].freeze
|
|
62
|
+
bins = edges.map { |frequency| ((@size + 1) * frequency / @sample_rate).floor }
|
|
63
|
+
highest = (@size / 2)
|
|
64
|
+
|
|
65
|
+
(0...@filters).map do |filter|
|
|
66
|
+
left = [bins[filter], 0].max
|
|
67
|
+
right = [bins[filter + 2], highest].min
|
|
68
|
+
center = bins[filter + 1].clamp(left + 1, [right - 1, left + 1].max)
|
|
69
|
+
|
|
70
|
+
Band.new(offset: left, weights: triangle(left, center, right).freeze)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def mel_spaced_edges
|
|
75
|
+
low_mel = Scales.hz_to_mel(@low)
|
|
76
|
+
high_mel = Scales.hz_to_mel(@high)
|
|
77
|
+
span = high_mel - low_mel
|
|
78
|
+
|
|
79
|
+
Array.new(@filters + 2) do |index|
|
|
80
|
+
Scales.mel_to_hz(low_mel + (span * index / (@filters + 1).to_f)) * @warp
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def triangle(left, center, right)
|
|
85
|
+
(left..right).map do |bin|
|
|
86
|
+
if bin < center
|
|
87
|
+
(bin - left).to_f / [center - left, 1].max
|
|
88
|
+
elsif bin == center
|
|
89
|
+
1.0
|
|
90
|
+
else
|
|
91
|
+
(right - bin).to_f / [right - center, 1].max
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/dsprb/mfcc.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
class Mfcc
|
|
5
|
+
DEFAULT_COEFFICIENTS = 13
|
|
6
|
+
DEFAULT_SIZE = 512
|
|
7
|
+
DEFAULT_WINDOW_SECONDS = 0.025
|
|
8
|
+
DEFAULT_HOP_SECONDS = 0.010
|
|
9
|
+
|
|
10
|
+
attr_reader :filterbank, :spectrum, :coefficients
|
|
11
|
+
|
|
12
|
+
def self.for(sample_rate:, size: DEFAULT_SIZE, coefficients: DEFAULT_COEFFICIENTS, **options)
|
|
13
|
+
new(
|
|
14
|
+
filterbank: MelFilterbank.new(sample_rate: sample_rate, size: size, **options),
|
|
15
|
+
coefficients: coefficients
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(filterbank:, coefficients: DEFAULT_COEFFICIENTS, spectrum: nil)
|
|
20
|
+
@filterbank = filterbank
|
|
21
|
+
@coefficients = Integer(coefficients)
|
|
22
|
+
@spectrum = spectrum || Spectrum.new(filterbank.size)
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "coefficients must be positive, got #{@coefficients}" unless @coefficients.positive?
|
|
25
|
+
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(power) = Dct.two(@filterbank.log_energies(power), @coefficients)
|
|
30
|
+
|
|
31
|
+
def sequence(
|
|
32
|
+
waveform,
|
|
33
|
+
window_seconds: DEFAULT_WINDOW_SECONDS,
|
|
34
|
+
hop_seconds: DEFAULT_HOP_SECONDS,
|
|
35
|
+
window: Window::DEFAULT,
|
|
36
|
+
preemphasis: Framing::DEFAULT_PREEMPHASIS
|
|
37
|
+
)
|
|
38
|
+
assert_rate!(waveform)
|
|
39
|
+
|
|
40
|
+
length = waveform.seconds_to_samples(window_seconds)
|
|
41
|
+
taper = Window.resolve(window).call(length)
|
|
42
|
+
emphasized = preemphasis ? Framing.preemphasis(waveform.samples, coefficient: preemphasis) : waveform.samples
|
|
43
|
+
|
|
44
|
+
Framing
|
|
45
|
+
.frames(emphasized, window: length, hop: waveform.seconds_to_samples(hop_seconds))
|
|
46
|
+
.map { |frame| call(@spectrum.power(Window.apply(frame, taper))) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def assert_rate!(waveform)
|
|
52
|
+
return if waveform.sample_rate == @filterbank.sample_rate
|
|
53
|
+
|
|
54
|
+
raise(
|
|
55
|
+
ArgumentError,
|
|
56
|
+
"waveform sample rate #{waveform.sample_rate} does not match the filterbank rate #{@filterbank.sample_rate}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Parabolic
|
|
5
|
+
FLAT_TOLERANCE = 1e-12
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Offset in samples of the vertex of the parabola through three equally
|
|
10
|
+
# spaced points, used to refine a discrete extremum to sub-sample accuracy.
|
|
11
|
+
def vertex_offset(previous, current, following)
|
|
12
|
+
curvature = (2.0 * current) - previous - following
|
|
13
|
+
return 0.0 if curvature.abs < FLAT_TOLERANCE
|
|
14
|
+
|
|
15
|
+
0.5 * (following - previous) / curvature
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
PitchTrack = Data.define(:f0, :confidence, :times, :hop_seconds) do
|
|
5
|
+
def length = f0.length
|
|
6
|
+
|
|
7
|
+
def empty? = f0.empty?
|
|
8
|
+
|
|
9
|
+
def voiced_frames = f0.count(&:positive?)
|
|
10
|
+
|
|
11
|
+
def voiced_ratio = f0.empty? ? 0.0 : voiced_frames.to_f / f0.length
|
|
12
|
+
|
|
13
|
+
def voiced_f0 = f0.select(&:positive?)
|
|
14
|
+
|
|
15
|
+
def each_voiced
|
|
16
|
+
return enum_for(:each_voiced) unless block_given?
|
|
17
|
+
|
|
18
|
+
f0.each_with_index do |frequency, index|
|
|
19
|
+
yield(frequency, times[index], confidence[index]) if frequency.positive?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Resolver
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(value, registry, kind)
|
|
8
|
+
return value if value.respond_to?(:call)
|
|
9
|
+
|
|
10
|
+
registry.fetch(value) do
|
|
11
|
+
raise(
|
|
12
|
+
UnknownStrategyError,
|
|
13
|
+
"unknown #{kind} #{value.inspect}: expected one of #{registry.keys.join(", ")} " \
|
|
14
|
+
"or an object responding to #call"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/dsprb/scales.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
module Scales
|
|
5
|
+
# O'Shaughnessy's mel scale, m = 2595 log10(1 + f / 700).
|
|
6
|
+
MEL_SCALE = 2595.0
|
|
7
|
+
MEL_BREAK_HZ = 700.0
|
|
8
|
+
|
|
9
|
+
SEMITONES_PER_OCTAVE = 12.0
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def hz_to_mel(hz) = MEL_SCALE * Math.log10(1.0 + (hz / MEL_BREAK_HZ))
|
|
14
|
+
|
|
15
|
+
def mel_to_hz(mel) = MEL_BREAK_HZ * ((10.0 ** (mel / MEL_SCALE)) - 1.0)
|
|
16
|
+
|
|
17
|
+
def hz_to_semitones(hz, reference:) = SEMITONES_PER_OCTAVE * Math.log2(hz / reference)
|
|
18
|
+
|
|
19
|
+
def semitones_to_hz(semitones, reference:) = reference * (2.0 ** (semitones / SEMITONES_PER_OCTAVE))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
SpectralMoments = Data.define(:centroid, :spread, :skewness, :kurtosis) do
|
|
5
|
+
# Below this the band carries no measurable energy and the moments are undefined.
|
|
6
|
+
ENERGY_FLOOR = 1e-12
|
|
7
|
+
VARIANCE_FLOOR = 1e-12
|
|
8
|
+
|
|
9
|
+
# Fricative energy sits well above the voiced band, so the default window starts high.
|
|
10
|
+
DEFAULT_LOW_HZ = 500.0
|
|
11
|
+
|
|
12
|
+
# Keeps the anti-aliasing roll-off out of the measured band.
|
|
13
|
+
HIGH_MARGIN_HZ = 200.0
|
|
14
|
+
|
|
15
|
+
def self.zero = new(centroid: 0.0, spread: 0.0, skewness: 0.0, kurtosis: 0.0)
|
|
16
|
+
|
|
17
|
+
def self.of(power, sample_rate:, size:, low: DEFAULT_LOW_HZ, high: nil)
|
|
18
|
+
ceiling = high || ((sample_rate / 2.0) - HIGH_MARGIN_HZ)
|
|
19
|
+
frequencies, weights, total = collect(power, sample_rate, size, low, ceiling)
|
|
20
|
+
return zero if total <= ENERGY_FLOOR
|
|
21
|
+
|
|
22
|
+
centroid = frequencies.each_with_index.sum { |frequency, index| frequency * weights[index] } / total
|
|
23
|
+
from_central_moments(centroid, central_moments(frequencies, weights, centroid, total))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.collect(power, sample_rate, size, low, high)
|
|
27
|
+
frequencies = []
|
|
28
|
+
weights = []
|
|
29
|
+
total = 0.0
|
|
30
|
+
|
|
31
|
+
index = 0
|
|
32
|
+
while index < power.length
|
|
33
|
+
frequency = index * sample_rate / size.to_f
|
|
34
|
+
if frequency >= low && frequency <= high
|
|
35
|
+
frequencies << frequency
|
|
36
|
+
weights << power[index]
|
|
37
|
+
total += power[index]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
index += 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[frequencies, weights, total]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.central_moments(frequencies, weights, centroid, total)
|
|
47
|
+
second = 0.0
|
|
48
|
+
third = 0.0
|
|
49
|
+
fourth = 0.0
|
|
50
|
+
|
|
51
|
+
index = 0
|
|
52
|
+
while index < frequencies.length
|
|
53
|
+
deviation = frequencies[index] - centroid
|
|
54
|
+
squared = deviation * deviation
|
|
55
|
+
weight = weights[index]
|
|
56
|
+
second += weight * squared
|
|
57
|
+
third += weight * squared * deviation
|
|
58
|
+
fourth += weight * squared * squared
|
|
59
|
+
index += 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
[second / total, third / total, fourth / total]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Kurtosis is reported in excess form, so a Gaussian spectrum yields zero.
|
|
66
|
+
def self.from_central_moments(centroid, moments)
|
|
67
|
+
second, third, fourth = moments
|
|
68
|
+
deviation = Math.sqrt([second, VARIANCE_FLOOR].max)
|
|
69
|
+
|
|
70
|
+
new(
|
|
71
|
+
centroid: centroid,
|
|
72
|
+
spread: deviation,
|
|
73
|
+
skewness: third / (deviation ** 3),
|
|
74
|
+
kurtosis: (fourth / (deviation ** 4)) - 3.0
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private_class_method :collect, :central_moments, :from_central_moments
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DSP
|
|
4
|
+
class Spectrum
|
|
5
|
+
attr_reader :size, :bins, :transform
|
|
6
|
+
|
|
7
|
+
def initialize(size, transform: nil)
|
|
8
|
+
@size = Integer(size)
|
|
9
|
+
@transform = transform || Fourier::Radix2.new(@size)
|
|
10
|
+
@bins = (@size / 2) + 1
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def power(frame)
|
|
15
|
+
real = Array.new(@size, 0.0)
|
|
16
|
+
imaginary = Array.new(@size, 0.0)
|
|
17
|
+
limit = [frame.length, @size].min
|
|
18
|
+
index = 0
|
|
19
|
+
while index < limit
|
|
20
|
+
real[index] = frame[index].to_f
|
|
21
|
+
index += 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@transform.call(real, imaginary)
|
|
25
|
+
|
|
26
|
+
Array.new(@bins) { |bin| (real[bin] * real[bin]) + (imaginary[bin] * imaginary[bin]) }
|
|
27
|
+
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
|
+
end
|
|
35
|
+
end
|