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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ VERSION = "1.0.0"
5
+ end