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.
data/lib/dsprb/wav.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ module Wav
5
+ PCM = 1
6
+ IEEE_FLOAT = 3
7
+
8
+ # WAVE_FORMAT_EXTENSIBLE stores the effective format in the first two bytes
9
+ # of its SubFormat GUID rather than in the format field itself.
10
+ EXTENSIBLE = 0xFFFE
11
+
12
+ HEADER_BYTES = 12
13
+ CHUNK_HEADER_BYTES = 8
14
+
15
+ # Unsigned 8-bit PCM is the one depth WAVE stores with a midpoint offset.
16
+ EIGHT_BIT_MIDPOINT = 128.0
17
+
18
+ module_function
19
+
20
+ def read(path) = decode(File.binread(path))
21
+
22
+ def decode(bytes)
23
+ assert_riff!(bytes)
24
+
25
+ format = nil
26
+ channels = nil
27
+ sample_rate = nil
28
+ bits = nil
29
+ data = nil
30
+ position = HEADER_BYTES
31
+
32
+ while position + CHUNK_HEADER_BYTES <= bytes.bytesize
33
+ identifier = bytes[position, 4]
34
+ size = bytes[position + 4, 4].unpack1("V")
35
+ body = bytes[position + CHUNK_HEADER_BYTES, size]
36
+
37
+ case identifier
38
+ when "fmt "
39
+ format, channels, sample_rate, _byte_rate, _align, bits = body.unpack("vvVVvv")
40
+ format = body[24, 2].unpack1("v") if format == EXTENSIBLE && body.bytesize >= 26
41
+ when "data"
42
+ data = body
43
+ end
44
+
45
+ position += CHUNK_HEADER_BYTES + size + (size.odd? ? 1 : 0)
46
+ end
47
+
48
+ raise FormatError, "no fmt chunk" if sample_rate.nil?
49
+ raise FormatError, "no data chunk" if data.nil?
50
+
51
+ Waveform.new(samples: downmix(decode_samples(data, format, bits), channels), sample_rate: sample_rate)
52
+ end
53
+
54
+ def assert_riff!(bytes)
55
+ return if bytes.bytesize >= HEADER_BYTES && bytes[0, 4] == "RIFF" && bytes[8, 4] == "WAVE"
56
+
57
+ raise FormatError, "not a RIFF/WAVE stream"
58
+ end
59
+
60
+ def decode_samples(data, format, bits)
61
+ case [format, bits]
62
+ in [PCM, 8]
63
+ data.unpack("C*").map { |value| (value - EIGHT_BIT_MIDPOINT) / EIGHT_BIT_MIDPOINT }
64
+ in [PCM, 16]
65
+ scale(data.unpack("s<*"), 15)
66
+ in [PCM, 24]
67
+ decode_packed_24(data)
68
+ in [PCM, 32]
69
+ scale(data.unpack("l<*"), 31)
70
+ in [IEEE_FLOAT, 32]
71
+ data.unpack("e*")
72
+ in [IEEE_FLOAT, 64]
73
+ data.unpack("E*")
74
+ else
75
+ raise FormatError, "unsupported encoding: format #{format.inspect}, #{bits.inspect} bit"
76
+ end
77
+ end
78
+
79
+ def scale(integers, magnitude_bits)
80
+ full_scale = (1 << magnitude_bits).to_f
81
+ integers.map { |value| value / full_scale }
82
+ end
83
+
84
+ def decode_packed_24(data)
85
+ sign_bit = 1 << 23
86
+ full_scale = sign_bit.to_f
87
+
88
+ data.unpack("C*").each_slice(3).filter_map do |low, middle, high|
89
+ next if high.nil?
90
+
91
+ value = (high << 16) | (middle << 8) | low
92
+ value -= sign_bit << 1 if value >= sign_bit
93
+ value / full_scale
94
+ end
95
+ end
96
+
97
+ def downmix(samples, channels)
98
+ return samples if channels.nil? || channels <= 1
99
+
100
+ Array.new(samples.length / channels) do |index|
101
+ offset = index * channels
102
+ total = 0.0
103
+ channel = 0
104
+ while channel < channels
105
+ total += samples[offset + channel]
106
+ channel += 1
107
+ end
108
+
109
+ total / channels
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ Waveform = Data.define(:samples, :sample_rate) do
5
+ def initialize(samples:, sample_rate:)
6
+ rate = Float(sample_rate)
7
+ raise ArgumentError, "sample rate must be positive, got #{rate}" unless rate.positive?
8
+
9
+ super(samples: samples, sample_rate: rate)
10
+ end
11
+
12
+ def length = samples.length
13
+
14
+ def duration = samples.length / sample_rate
15
+
16
+ def nyquist = sample_rate / 2.0
17
+
18
+ def empty? = samples.empty?
19
+
20
+ def frames(window:, hop:) = Framing.frames(samples, window: window, hop: hop)
21
+
22
+ def seconds_to_samples(seconds) = (seconds * sample_rate).round
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ module Window
5
+ # Generalized cosine windows w[i] = sum_k (-1)^k a_k cos(2 pi k i / (N - 1)).
6
+ HAMMING = [0.54, 0.46].freeze
7
+ HANN = [0.5, 0.5].freeze
8
+ BLACKMAN = [0.42, 0.5, 0.08].freeze
9
+
10
+ module_function
11
+
12
+ def rectangular(length)
13
+ assert_length!(length)
14
+
15
+ Array.new(length, 1.0)
16
+ end
17
+
18
+ def hamming(length) = cosine_sum(length, HAMMING)
19
+
20
+ def hann(length) = cosine_sum(length, HANN)
21
+
22
+ def blackman(length) = cosine_sum(length, BLACKMAN)
23
+
24
+ def cosine_sum(length, coefficients)
25
+ assert_length!(length)
26
+ return [1.0] if length == 1
27
+
28
+ span = (length - 1).to_f
29
+
30
+ Array.new(length) do |index|
31
+ phase = 2.0 * Math::PI * index / span
32
+ coefficients.each_with_index.sum { |weight, term| (term.even? ? weight : -weight) * Math.cos(term * phase) }
33
+ end
34
+ end
35
+
36
+ def apply(frame, window) = Array.new(frame.length) { |index| frame[index] * window[index] }
37
+
38
+ def assert_length!(length)
39
+ return if length.is_a?(Integer) && length.positive?
40
+
41
+ raise ArgumentError, "window length must be a positive Integer, got #{length.inspect}"
42
+ end
43
+
44
+ REGISTRY = {
45
+ rectangular: method(:rectangular),
46
+ hamming: method(:hamming),
47
+ hann: method(:hann),
48
+ blackman: method(:blackman)
49
+ }.freeze
50
+
51
+ DEFAULT = :hamming
52
+
53
+ def resolve(window) = Resolver.call(window, REGISTRY, "window")
54
+ end
55
+ end
data/lib/dsprb/yin.rb ADDED
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSP
4
+ class Yin
5
+ DEFAULT_MINIMUM_HZ = 70.0
6
+ DEFAULT_MAXIMUM_HZ = 500.0
7
+ DEFAULT_HOP_SECONDS = 0.008
8
+
9
+ # Absolute threshold of the YIN paper: the first dip of the cumulative mean
10
+ # normalized difference below it is taken as the period.
11
+ DEFAULT_THRESHOLD = 0.15
12
+
13
+ # A global minimum above this is too shallow to call the frame voiced.
14
+ DEFAULT_VOICED_THRESHOLD = 0.35
15
+
16
+ # Decimation target, an order above twice the highest tracked f0, so that
17
+ # periodicity survives while the O(window * lags) search stays cheap.
18
+ DEFAULT_WORKING_RATE = 5512.5
19
+
20
+ # Bounds the lag search for pathological rate and frequency combinations.
21
+ MAXIMUM_LAG = 10_000
22
+
23
+ attr_reader :minimum_frequency, :maximum_frequency, :hop_seconds, :threshold, :voiced_threshold, :working_rate
24
+
25
+ def initialize(
26
+ minimum_frequency: DEFAULT_MINIMUM_HZ,
27
+ maximum_frequency: DEFAULT_MAXIMUM_HZ,
28
+ hop_seconds: DEFAULT_HOP_SECONDS,
29
+ threshold: DEFAULT_THRESHOLD,
30
+ voiced_threshold: DEFAULT_VOICED_THRESHOLD,
31
+ working_rate: DEFAULT_WORKING_RATE
32
+ )
33
+ @minimum_frequency = Float(minimum_frequency)
34
+ @maximum_frequency = Float(maximum_frequency)
35
+ @hop_seconds = Float(hop_seconds)
36
+ @threshold = Float(threshold)
37
+ @voiced_threshold = Float(voiced_threshold)
38
+ @working_rate = Float(working_rate)
39
+
40
+ validate!
41
+ freeze
42
+ end
43
+
44
+ def call(waveform)
45
+ reduced = Decimator.new(Decimator.factor_for(waveform.sample_rate, @working_rate)).call(waveform)
46
+ rate = reduced.sample_rate
47
+ shortest = (rate / @maximum_frequency).floor.clamp(2, MAXIMUM_LAG)
48
+ longest = (rate / @minimum_frequency).ceil.clamp(shortest + 2, MAXIMUM_LAG)
49
+ window = longest * 2
50
+ hop = [(rate * @hop_seconds).round, 1].max
51
+
52
+ track(reduced.samples, rate: rate, window: window, hop: hop, shortest: shortest, longest: longest)
53
+ end
54
+
55
+ private
56
+
57
+ def validate!
58
+ unless @minimum_frequency.positive? && @minimum_frequency < @maximum_frequency
59
+ raise(
60
+ ArgumentError,
61
+ "expected 0 < minimum_frequency < maximum_frequency, " \
62
+ "got #{@minimum_frequency} and #{@maximum_frequency}"
63
+ )
64
+ end
65
+
66
+ raise ArgumentError, "hop_seconds must be positive, got #{@hop_seconds}" unless @hop_seconds.positive?
67
+ raise ArgumentError, "working_rate must be positive, got #{@working_rate}" unless @working_rate.positive?
68
+ end
69
+
70
+ def track(samples, rate:, window:, hop:, shortest:, longest:)
71
+ f0 = []
72
+ confidence = []
73
+ times = []
74
+
75
+ position = 0
76
+ while position + window + longest <= samples.length
77
+ normalized = cumulative_mean_difference(samples, position, window, shortest, longest)
78
+ frequency, weight = evaluate(normalized, select_lag(normalized, shortest, longest), shortest, longest, rate)
79
+ f0 << frequency
80
+ confidence << weight
81
+ times << (position / rate)
82
+ position += hop
83
+ end
84
+
85
+ PitchTrack.new(f0: f0, confidence: confidence, times: times, hop_seconds: hop / rate)
86
+ end
87
+
88
+ def cumulative_mean_difference(samples, position, window, shortest, longest)
89
+ raw = squared_difference(samples, position, window, shortest, longest)
90
+ normalized = Array.new(longest + 1, 1.0)
91
+ running = 0.0
92
+
93
+ lag = shortest
94
+ while lag <= longest
95
+ running += raw[lag]
96
+ normalized[lag] = running.zero? ? 1.0 : raw[lag] * (lag - shortest + 1) / running
97
+ lag += 1
98
+ end
99
+
100
+ normalized
101
+ end
102
+
103
+ def squared_difference(samples, position, window, shortest, longest)
104
+ raw = Array.new(longest + 1, 0.0)
105
+
106
+ lag = shortest
107
+ while lag <= longest
108
+ total = 0.0
109
+ index = 0
110
+ while index < window
111
+ delta = samples[position + index] - samples[position + index + lag]
112
+ total += delta * delta
113
+ index += 1
114
+ end
115
+
116
+ raw[lag] = total
117
+ lag += 1
118
+ end
119
+
120
+ raw
121
+ end
122
+
123
+ def select_lag(normalized, shortest, longest)
124
+ lag = shortest + 1
125
+ while lag < longest
126
+ return lag if normalized[lag] < @threshold && normalized[lag] <= normalized[lag + 1]
127
+
128
+ lag += 1
129
+ end
130
+
131
+ global_minimum(normalized, shortest, longest)
132
+ end
133
+
134
+ def global_minimum(normalized, shortest, longest)
135
+ best = shortest
136
+ lowest = normalized[shortest]
137
+
138
+ lag = shortest + 1
139
+ while lag <= longest
140
+ if normalized[lag] < lowest
141
+ lowest = normalized[lag]
142
+ best = lag
143
+ end
144
+
145
+ lag += 1
146
+ end
147
+
148
+ best
149
+ end
150
+
151
+ def evaluate(normalized, lag, shortest, longest, rate)
152
+ return [0.0, 0.0] unless lag > shortest && lag < longest
153
+
154
+ offset = Parabolic.vertex_offset(normalized[lag - 1], normalized[lag], normalized[lag + 1])
155
+ frequency = rate / (lag + offset)
156
+ dip = normalized[lag]
157
+ return [0.0, 0.0] unless dip < @voiced_threshold && frequency.between?(@minimum_frequency, @maximum_frequency)
158
+
159
+ [frequency, (1.0 - dip).clamp(0.0, 1.0)]
160
+ end
161
+ end
162
+ end
data/lib/dsprb.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsprb/version"
4
+ require_relative "dsprb/errors"
5
+ require_relative "dsprb/resolver"
6
+ require_relative "dsprb/parabolic"
7
+ require_relative "dsprb/waveform"
8
+ require_relative "dsprb/wav"
9
+ require_relative "dsprb/window"
10
+ require_relative "dsprb/framing"
11
+ require_relative "dsprb/fourier"
12
+ require_relative "dsprb/spectrum"
13
+ require_relative "dsprb/scales"
14
+ require_relative "dsprb/mel_filterbank"
15
+ require_relative "dsprb/dct"
16
+ require_relative "dsprb/mfcc"
17
+ require_relative "dsprb/decimator"
18
+ require_relative "dsprb/autocorrelation"
19
+ require_relative "dsprb/lpc"
20
+ require_relative "dsprb/formants"
21
+ require_relative "dsprb/pitch_track"
22
+ require_relative "dsprb/yin"
23
+ require_relative "dsprb/spectral_moments"
24
+ require_relative "dsprb/energy"
25
+ require_relative "dsprb/biquad"
26
+ require_relative "dsprb/curve"
27
+
28
+ module DSP
29
+ module_function
30
+
31
+ def read(path) = Wav.read(path)
32
+
33
+ def decode(bytes) = Wav.decode(bytes)
34
+
35
+ def pitch(waveform, **options) = Yin.new(**options).call(waveform)
36
+
37
+ def formants(waveform, **options) = Formants.new(**options).call(waveform)
38
+
39
+ def mfcc(
40
+ waveform,
41
+ size: Mfcc::DEFAULT_SIZE,
42
+ coefficients: Mfcc::DEFAULT_COEFFICIENTS,
43
+ window_seconds: Mfcc::DEFAULT_WINDOW_SECONDS,
44
+ hop_seconds: Mfcc::DEFAULT_HOP_SECONDS,
45
+ **options
46
+ )
47
+ Mfcc
48
+ .for(sample_rate: waveform.sample_rate, size: size, coefficients: coefficients, **options)
49
+ .sequence(waveform, window_seconds: window_seconds, hop_seconds: hop_seconds)
50
+ end
51
+
52
+ def decimate(waveform, factor) = Decimator.new(factor).call(waveform)
53
+
54
+ def lowpass(waveform, cutoff:, sections: 2)
55
+ filter(waveform, Biquad.lowpass(sample_rate: waveform.sample_rate, cutoff: cutoff), sections)
56
+ end
57
+
58
+ def highpass(waveform, cutoff:, sections: 2)
59
+ filter(waveform, Biquad.highpass(sample_rate: waveform.sample_rate, cutoff: cutoff), sections)
60
+ end
61
+
62
+ def filter(waveform, biquad, sections)
63
+ waveform.with(samples: biquad.apply(waveform.samples, sections: sections))
64
+ end
65
+ end