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
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
|
data/lib/dsprb/window.rb
ADDED
|
@@ -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
|