pulse-analysis 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +13 -0
- data/README.md +106 -0
- data/bin/pulse-analysis +93 -0
- data/lib/pulse-analysis/analysis.rb +213 -0
- data/lib/pulse-analysis/audio_data.rb +54 -0
- data/lib/pulse-analysis/console/table.rb +84 -0
- data/lib/pulse-analysis/console.rb +8 -0
- data/lib/pulse-analysis/conversion.rb +43 -0
- data/lib/pulse-analysis/file.rb +47 -0
- data/lib/pulse-analysis/report.rb +151 -0
- data/lib/pulse-analysis/sound.rb +63 -0
- data/lib/pulse-analysis.rb +39 -0
- metadata +159 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 13321ee37a8c9ee41fe615fae126c0a3b2837bda
|
4
|
+
data.tar.gz: 1b6a15cdb0924fb4bd09d64d5f8b6d2a25673455
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c2a9b965a5210d0629915f5dbffe0aedc6bbb1e5afe4ac6f3d622a78fc3c233ea137f7a4725b8d0b97d2793284c03d74c502a4cab2cfa0027d21ff3f7771cc54
|
7
|
+
data.tar.gz: 8f2195c44bc4b2f8445a9f5d771977b224c95802e57372286dc57f8093ee24eb3d46e05ecd58b425e1c157fd5703bb5ba414231255f628f0c328ce4ce26515a4
|
data/LICENSE
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright 2017 Ari Russo
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
# Pulse Analysis
|
2
|
+
|
3
|
+
Using an audio file, measure pulses for timing deviation
|
4
|
+
|
5
|
+
This would generally be used to measure the timing accuracy of drum machines, sequencers and other music electronics. Inspired by the [Inner Clock Systems Litmus Test](http://innerclocksystems.com/New%20ICS%20Litmus.html)
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
The package [libsndfile](https://github.com/erikd/libsndfile) must be installed first. It's available in *Homebrew*, *APT*, *Yum* as well as many other package managers. For those who wish to compile themselves or need more information, follow the link above for more information
|
10
|
+
|
11
|
+
Install the gem itself using
|
12
|
+
|
13
|
+
```sh
|
14
|
+
gem install pulse-analysis
|
15
|
+
```
|
16
|
+
|
17
|
+
Or if you're using Bundler, add this to your Gemfile
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem "pulse-analysis"
|
21
|
+
```
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
### Input file
|
26
|
+
|
27
|
+
Pulse-Analysis operates on a single input audio file at a time. This file must be in an uncompressed format such as *WAV* or *AIFF*.
|
28
|
+
|
29
|
+
In keeping with conventions established by the Litmus Test, audio input files must be at least *48k* sample rate.
|
30
|
+
|
31
|
+
Mono audio files are recommended. If a stereo file is used, only the left channel will be analyzed.
|
32
|
+
|
33
|
+
It's recommended that the audio file have a pulse rate of *16th notes* at *120 BPM* and be around *10 minutes long*. In other words, to produce the best results, use only a single repetitive pulse-like sound (eg snare drum) striking 16th notes.
|
34
|
+
|
35
|
+
Example audio files are included in the repository and can be found [here](https://github.com/arirusso/pulse-analysis/tree/master/spec/media)
|
36
|
+
|
37
|
+
### Command Line
|
38
|
+
|
39
|
+
```sh
|
40
|
+
pulse-analysis /path/to/a/sound/file.wav
|
41
|
+
```
|
42
|
+
|
43
|
+
This will run the program and output something like
|
44
|
+
|
45
|
+
```sh
|
46
|
+
[/] Reading file /path/to/a/sound/file.wav Done!
|
47
|
+
[/] Running analysis Done!
|
48
|
+
[\] Generating Report Done!
|
49
|
+
|
50
|
+
+------------------------+-------------------------+-------------+
|
51
|
+
| Pulse Analysis |
|
52
|
+
+------------------------+-------------------------+-------------+
|
53
|
+
| Item | Value |
|
54
|
+
+------------------------+-------------------------+-------------+
|
55
|
+
| Sample rate | 88200 (Hertz) |
|
56
|
+
| Length | 4311 (Number of pulses) | 9m0s (Time) |
|
57
|
+
| Tempo | 119.9371 (BPM) |
|
58
|
+
| Longest period length | 11289 (Samples) | 127.99 (MS) |
|
59
|
+
| Shortest period length | 10687 (Samples) | 121.17 (MS) |
|
60
|
+
| Average period length | 11030.7838 (Samples) | 125.07 (MS) |
|
61
|
+
| Largest abberation | 543 (Samples) | 6.16 (MS) |
|
62
|
+
| Average abberation | 155.5279 (Samples) | 1.76 (MS) |
|
63
|
+
+------------------------+-------------------------+-------------+
|
64
|
+
```
|
65
|
+
|
66
|
+
### In Ruby
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
2.4.0 :002 > require "pulse-analysis"
|
70
|
+
=> true
|
71
|
+
|
72
|
+
2.4.0 :003 > PulseAnalysis.report("/path/to/a/sound/file.wav")
|
73
|
+
=> {
|
74
|
+
:file=>{
|
75
|
+
:path=>"/path/to/a/sound/file.wav"},
|
76
|
+
:analysis=>[
|
77
|
+
{
|
78
|
+
:key=>:sample_rate,
|
79
|
+
:description=>"Sample rate",
|
80
|
+
:value=>{
|
81
|
+
:unit=>"Hertz",
|
82
|
+
:value=>88200
|
83
|
+
}
|
84
|
+
},
|
85
|
+
{
|
86
|
+
:key=>:tempo,
|
87
|
+
:description=>"Tempo",
|
88
|
+
:value=>{
|
89
|
+
:unit=>"BPM",
|
90
|
+
:value=>119.9371
|
91
|
+
}
|
92
|
+
},
|
93
|
+
...
|
94
|
+
```
|
95
|
+
|
96
|
+
## Disclaimer
|
97
|
+
|
98
|
+
With the subjective nature of the data at hand, this program is not meant to reflect poorly on any musicians, companies, hobbyists or anyone whose product has measurable timing. After all, variation in timing may be desirable in musical context.
|
99
|
+
|
100
|
+
Additionally, it's recommended that results be independently verified. The configuration of a particular device or recording environment can cause variation in timing accuracy.
|
101
|
+
|
102
|
+
## License
|
103
|
+
|
104
|
+
Licensed under Apache 2.0, See the file LICENSE
|
105
|
+
|
106
|
+
Copyright (c) 2017 [Ari Russo](http://arirusso.com)
|
data/bin/pulse-analysis
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
3
|
+
|
4
|
+
require "pulse-analysis"
|
5
|
+
require "pulse-analysis/console"
|
6
|
+
|
7
|
+
require "optparse"
|
8
|
+
require "tty-spinner"
|
9
|
+
|
10
|
+
def help(opts)
|
11
|
+
puts(opts)
|
12
|
+
exit
|
13
|
+
end
|
14
|
+
|
15
|
+
options = {}
|
16
|
+
|
17
|
+
parser = OptionParser.new do |opts|
|
18
|
+
|
19
|
+
opts.banner = "Usage: pulse-analysis [file] [options]"
|
20
|
+
opts.separator ""
|
21
|
+
opts.separator "Specific options:"
|
22
|
+
|
23
|
+
opts.on("-aAMPLITUDE", "--amplitude_threshold=AMPLITUDE", "Amplitude Threshold") do |amplitude|
|
24
|
+
options[:amplitude_threshold] = amplitude.to_f
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on("-lLENGTH", "--length_threshold=LENGTH", "Length Threshold") do |length|
|
28
|
+
options[:length_threshold] = length.to_i
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("-q", "--quiet", "Quiet") do |quiet|
|
32
|
+
options[:quiet] = quiet
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on("-oFILE", "--output-file=FILE", "Output File (YAML)") do |file|
|
36
|
+
options[:output_file] = file
|
37
|
+
end
|
38
|
+
|
39
|
+
opts.on_tail("-h", "--help", "Show this message") { help(opts) }
|
40
|
+
|
41
|
+
opts.on_tail("--version", "Show version") do
|
42
|
+
puts PulseAnalysis::VERSION
|
43
|
+
exit
|
44
|
+
end
|
45
|
+
|
46
|
+
help(opts) if ARGV.empty?
|
47
|
+
end
|
48
|
+
|
49
|
+
parser.parse!
|
50
|
+
|
51
|
+
unless options[:output_file].nil?
|
52
|
+
File.open(options[:output_file], "w") do |f|
|
53
|
+
f.write("")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
sound = PulseAnalysis::Sound.load(ARGV[0])
|
58
|
+
|
59
|
+
if options[:quiet]
|
60
|
+
analysis = PulseAnalysis::Analysis.new(sound, options)
|
61
|
+
analysis.run
|
62
|
+
analysis.validate
|
63
|
+
report = PulseAnalysis::Report.new(analysis)
|
64
|
+
else
|
65
|
+
spinner = TTY::Spinner.new("[:spinner] :title")
|
66
|
+
analysis = nil
|
67
|
+
report = nil
|
68
|
+
|
69
|
+
spinner.update(title: "Reading file #{ARGV[0]}")
|
70
|
+
spinner.run("Done!") { analysis = PulseAnalysis::Analysis.new(sound, options) }
|
71
|
+
|
72
|
+
spinner.update(title: "Running analysis")
|
73
|
+
spinner.run("Done!") do
|
74
|
+
analysis.run
|
75
|
+
analysis.validate
|
76
|
+
end
|
77
|
+
|
78
|
+
spinner.update(title: "Generating Report")
|
79
|
+
spinner.run("Done!") { report = PulseAnalysis::Report.new(analysis) }
|
80
|
+
|
81
|
+
puts
|
82
|
+
|
83
|
+
table = PulseAnalysis::Console::Table.build(report)
|
84
|
+
puts(table.content)
|
85
|
+
end
|
86
|
+
|
87
|
+
unless options[:output_file].nil?
|
88
|
+
require "yaml"
|
89
|
+
|
90
|
+
File.open(options[:output_file], "w") do |f|
|
91
|
+
f.write(output.to_h.to_yaml)
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
class Analysis
|
4
|
+
|
5
|
+
MINIMUM_PULSES = 10
|
6
|
+
|
7
|
+
attr_reader :abberations, :data, :periods, :sound
|
8
|
+
|
9
|
+
# @param [PulseAnalysis::Sound] sound Sound to analyze
|
10
|
+
# @param [Hash] options
|
11
|
+
# @option options [Float] :amplitude_threshold Pulses above this amplitude will be analyzed
|
12
|
+
# @option options [Integer] :length_threshold Pulse periods longer than this value will be analyzed
|
13
|
+
def initialize(sound, options = {})
|
14
|
+
@amplitude_threshold = options[:amplitude_threshold]
|
15
|
+
@length_threshold = options[:length_threshold]
|
16
|
+
populate_sound(sound)
|
17
|
+
@data = AudioData.new(@sound)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Run the analysis
|
21
|
+
# @return [Boolean]
|
22
|
+
def run
|
23
|
+
prepare
|
24
|
+
populate_periods
|
25
|
+
validate
|
26
|
+
true
|
27
|
+
end
|
28
|
+
|
29
|
+
# Average number of samples between pulses
|
30
|
+
# @return [Float]
|
31
|
+
def average_period
|
32
|
+
@average_period ||= @periods.inject(&:+).to_f / num_pulses
|
33
|
+
end
|
34
|
+
|
35
|
+
# Number of usable pulses in the audio file
|
36
|
+
# @return [Integer]
|
37
|
+
def num_pulses
|
38
|
+
@num_pulses ||= @periods.count
|
39
|
+
end
|
40
|
+
|
41
|
+
# Longest number of samples between pulse
|
42
|
+
# @return [Integer]
|
43
|
+
def longest_period
|
44
|
+
@longest_period ||= @periods.max
|
45
|
+
end
|
46
|
+
|
47
|
+
# Shortest number of samples between pulse
|
48
|
+
# @return [Integer]
|
49
|
+
def shortest_period
|
50
|
+
@shortest_period ||= @periods.min
|
51
|
+
end
|
52
|
+
|
53
|
+
# Tempo of the audio file in beats per minute (BPM)
|
54
|
+
# Assumes that the pulse is 16th notes as per the Innerclock
|
55
|
+
# Litmus Test
|
56
|
+
# @return [Float]
|
57
|
+
def tempo_bpm
|
58
|
+
@tempo_bpm ||= calculate_tempo_bpm
|
59
|
+
end
|
60
|
+
|
61
|
+
# Largest sequential abberation between pulses
|
62
|
+
# @return [Integer]
|
63
|
+
def largest_abberation
|
64
|
+
@largest_abberation ||= abberations.max || 0
|
65
|
+
end
|
66
|
+
|
67
|
+
# Average sequential abberation between pulses
|
68
|
+
# @return [Float]
|
69
|
+
def average_abberation
|
70
|
+
@average_abberation ||= calculate_average_abberation
|
71
|
+
end
|
72
|
+
|
73
|
+
# Non-zero pulse timing abberations derived from the periods
|
74
|
+
# @return [Array<Integer>]
|
75
|
+
def abberations
|
76
|
+
@abberations ||= calculate_abberations
|
77
|
+
end
|
78
|
+
|
79
|
+
# The threshold (0..1) at which pulses will register as high
|
80
|
+
# @return [Float]
|
81
|
+
def amplitude_threshold
|
82
|
+
@amplitude_threshold ||= calculate_amplitude_threshold
|
83
|
+
end
|
84
|
+
|
85
|
+
# Validate that analysis on the given data can produce
|
86
|
+
# meaningful results
|
87
|
+
# @return [Boolean]
|
88
|
+
def validate
|
89
|
+
if valid?
|
90
|
+
true
|
91
|
+
else
|
92
|
+
message = "Could not produce a valid analysis."
|
93
|
+
raise(message)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Validate that the analysis has produced meaningful results
|
98
|
+
# @return [Boolean]
|
99
|
+
def valid?
|
100
|
+
@periods.count > MINIMUM_PULSES
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def prepare
|
106
|
+
@data.prepare
|
107
|
+
end
|
108
|
+
|
109
|
+
# Calculate the average deviation from timing of one period to
|
110
|
+
# the next
|
111
|
+
# @return [Float]
|
112
|
+
def calculate_average_abberation
|
113
|
+
if abberations.empty?
|
114
|
+
0.0
|
115
|
+
else
|
116
|
+
abberations.inject(&:+).to_f / abberations.count
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Calculate the rhythmic tempo of the sound in beats per minute
|
121
|
+
# @return [Float]
|
122
|
+
def calculate_tempo_bpm
|
123
|
+
seconds = average_period / @sound.sample_rate
|
124
|
+
division = 4 # 16th notes
|
125
|
+
60 / seconds / division
|
126
|
+
end
|
127
|
+
|
128
|
+
# Load the sound file and validate the data
|
129
|
+
# @param [PulseAnalysis::Sound] sound Sound to analyze
|
130
|
+
# @return [PulseAnalysis::Sound]
|
131
|
+
def populate_sound(sound)
|
132
|
+
@sound = sound
|
133
|
+
@sound.validate_for_analysis
|
134
|
+
@sound
|
135
|
+
end
|
136
|
+
|
137
|
+
# Populate the instance with abberations derived from the periods
|
138
|
+
# @return [Array<Integer>]
|
139
|
+
def calculate_abberations
|
140
|
+
i = 0
|
141
|
+
abberations = []
|
142
|
+
@periods.each do |period|
|
143
|
+
unless i.zero?
|
144
|
+
last_period = @periods[i - 1]
|
145
|
+
abberations << period - last_period
|
146
|
+
end
|
147
|
+
i += 1
|
148
|
+
end
|
149
|
+
abberations.reject!(&:zero?)
|
150
|
+
abberations.map(&:abs)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Calcuate the threshold at which pulses will register as high
|
154
|
+
# This is derived from the audio data
|
155
|
+
# @return [Float]
|
156
|
+
def calculate_amplitude_threshold
|
157
|
+
@data.max * 0.8
|
158
|
+
end
|
159
|
+
|
160
|
+
# Threshold at which periods will be disregarded if they are shorter than
|
161
|
+
# Defaults to 80% of the average period size
|
162
|
+
# @param [Array<Integer>]
|
163
|
+
# @return [Integer]
|
164
|
+
def length_threshold(raw_periods)
|
165
|
+
if @length_threshold.nil?
|
166
|
+
average_period = raw_periods.inject(&:+).to_f / raw_periods.count
|
167
|
+
@length_threshold = (average_period * 0.8).to_i
|
168
|
+
end
|
169
|
+
@length_threshold
|
170
|
+
end
|
171
|
+
|
172
|
+
# Calculate periods between pulses
|
173
|
+
# @return [Array<Integer>]
|
174
|
+
def populate_periods
|
175
|
+
is_low = false
|
176
|
+
periods = []
|
177
|
+
period_index = 0
|
178
|
+
@data.each do |frame|
|
179
|
+
if frame.abs < amplitude_threshold # if pulse is low
|
180
|
+
is_low = true
|
181
|
+
periods[period_index] ||= 0
|
182
|
+
# count period length
|
183
|
+
periods[period_index] += 1
|
184
|
+
else
|
185
|
+
# pulse is high
|
186
|
+
if is_low # last frame, the pulse was low
|
187
|
+
is_low = false
|
188
|
+
# move to next period
|
189
|
+
period_index += 1
|
190
|
+
end
|
191
|
+
# if the pulse was already high, don't do anything
|
192
|
+
end
|
193
|
+
end
|
194
|
+
prune_periods(periods)
|
195
|
+
@periods = periods
|
196
|
+
end
|
197
|
+
|
198
|
+
# Remove any possibly malformed periods from the calculated set
|
199
|
+
# @return [Array<Integer>]
|
200
|
+
def prune_periods(periods)
|
201
|
+
# remove the first and last periods in case recording wasn't started/
|
202
|
+
# stopped in sync
|
203
|
+
periods.shift
|
204
|
+
periods.pop
|
205
|
+
# remove periods that are below length threshold
|
206
|
+
length = length_threshold(periods)
|
207
|
+
periods.reject! { |period| period < length }
|
208
|
+
periods
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
|
213
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
class AudioData
|
4
|
+
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
def_delegators :@data, :[], :count, :each, :length, :max, :min, :size
|
8
|
+
|
9
|
+
# @param [PulseAnalysis::Sound] sound
|
10
|
+
def initialize(sound)
|
11
|
+
@sound = sound
|
12
|
+
@data = @sound.data
|
13
|
+
end
|
14
|
+
|
15
|
+
# Prepare the audio data for analysis
|
16
|
+
# @return [Boolean]
|
17
|
+
def prepare
|
18
|
+
convert_to_mono if convert_to_mono?
|
19
|
+
normalize if normalize?
|
20
|
+
true
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Should the audio data be normalized?
|
26
|
+
# @return [Boolean]
|
27
|
+
def normalize?
|
28
|
+
headroom = 1.0 - @data.max
|
29
|
+
headroom > 0.0
|
30
|
+
end
|
31
|
+
|
32
|
+
# Normalize the audio data
|
33
|
+
# @return [Array<Float>]
|
34
|
+
def normalize
|
35
|
+
factor = 1.0 / @data.max
|
36
|
+
@data.map! { |frame| frame * factor }
|
37
|
+
end
|
38
|
+
|
39
|
+
# Should the audio data be converted to a single channel?
|
40
|
+
# @return [Boolean]
|
41
|
+
def convert_to_mono?
|
42
|
+
@sound.num_channels > 1
|
43
|
+
end
|
44
|
+
|
45
|
+
# Logic for converting a stereo sound to mono
|
46
|
+
# @return [Array<Float>]
|
47
|
+
def convert_to_mono
|
48
|
+
# Use left channel
|
49
|
+
@data = @data.map(&:first)
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require "terminal-table"
|
2
|
+
|
3
|
+
module PulseAnalysis
|
4
|
+
|
5
|
+
module Console
|
6
|
+
|
7
|
+
class Table
|
8
|
+
|
9
|
+
attr_reader :content
|
10
|
+
|
11
|
+
# @param [PulseAnalysis::Report] report
|
12
|
+
# @return [PulseAnalysis::Console::Table]
|
13
|
+
def self.build(report)
|
14
|
+
table = new(report)
|
15
|
+
table.build
|
16
|
+
table
|
17
|
+
end
|
18
|
+
|
19
|
+
# @param [PulseAnalysis::Report] report
|
20
|
+
def initialize(report)
|
21
|
+
@report = report
|
22
|
+
end
|
23
|
+
|
24
|
+
# Populate the table content
|
25
|
+
# @return [Terminal::Table]
|
26
|
+
def build
|
27
|
+
@content = Terminal::Table.new(title: "Pulse Analysis", headings: header) do |table|
|
28
|
+
@report.items.each { |item| table << build_row(item) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
# Table header
|
35
|
+
# @return [Array<Hash, String>]
|
36
|
+
def header
|
37
|
+
[
|
38
|
+
"Item",
|
39
|
+
{
|
40
|
+
value: "Value",
|
41
|
+
colspan: value_colspan
|
42
|
+
}
|
43
|
+
]
|
44
|
+
end
|
45
|
+
|
46
|
+
# How many columns should the 'value' row be?
|
47
|
+
# @return [Integer]
|
48
|
+
def value_colspan
|
49
|
+
@value_colspan ||= @report.items.map { |item| item[:value] }.map(&:count).max
|
50
|
+
end
|
51
|
+
|
52
|
+
# Build a single table row with the given report item
|
53
|
+
# @param [Hash] item
|
54
|
+
# @return [Array<String>]
|
55
|
+
def build_row(item)
|
56
|
+
row = [item[:description]]
|
57
|
+
if item[:value].kind_of?(Array)
|
58
|
+
# item has multiple units of measure
|
59
|
+
item[:value].each_with_index do |value, i|
|
60
|
+
cell = {
|
61
|
+
value: "#{value[:value]} (#{value[:unit]})",
|
62
|
+
}
|
63
|
+
# make up remaining colspan
|
64
|
+
if item[:value].last == value
|
65
|
+
cell[:colspan] = value_colspan - i
|
66
|
+
end
|
67
|
+
row << cell
|
68
|
+
end
|
69
|
+
else
|
70
|
+
# item has single unit of measure
|
71
|
+
value = item[:value]
|
72
|
+
row << {
|
73
|
+
value: "#{value[:value]} (#{value[:unit]})",
|
74
|
+
colspan: value_colspan # full width
|
75
|
+
}
|
76
|
+
end
|
77
|
+
row
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
module Conversion
|
4
|
+
|
5
|
+
extend self
|
6
|
+
|
7
|
+
# Convert a quantity of samples to seconds with regard to the sample
|
8
|
+
# rate
|
9
|
+
# @param [Integer] sample_rate Sample rate in hertz (eg 88200)
|
10
|
+
# @param [Integer] num_samples
|
11
|
+
# @return [Float]
|
12
|
+
def num_samples_to_seconds(sample_rate, num_samples)
|
13
|
+
num_samples.to_f / sample_rate
|
14
|
+
end
|
15
|
+
|
16
|
+
# Convert a quantity of samples to a formatted time string with regard
|
17
|
+
# to the sample rate. (eg "1m20s", "2m22.4s"
|
18
|
+
# @param [Integer] sample_rate Sample rate in hertz (eg 88200)
|
19
|
+
# @param [Integer] num_samples
|
20
|
+
# @return [String]
|
21
|
+
def num_samples_to_formatted_time(sample_rate, num_samples)
|
22
|
+
min, sec = *num_samples_to_seconds(sample_rate, num_samples).divmod(60)
|
23
|
+
# convert seconds to int if it has no decimal value
|
24
|
+
if sec.to_i % sec == 0
|
25
|
+
sec = sec.to_i
|
26
|
+
end
|
27
|
+
# only include minutes if there is a value
|
28
|
+
result = min > 0 ? "#{min}m" : ""
|
29
|
+
result + "#{sec}s"
|
30
|
+
end
|
31
|
+
|
32
|
+
# Convert a quantity of samples to milliseconds with regard to the
|
33
|
+
# sample rate
|
34
|
+
# @param [Integer] sample_rate Sample rate in hertz (eg 88200)
|
35
|
+
# @param [Integer] num_samples
|
36
|
+
# @return [Float]
|
37
|
+
def num_samples_to_millis(sample_rate, num_samples)
|
38
|
+
(num_samples.to_f / sample_rate) * 1000
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
# An audio file
|
4
|
+
class File
|
5
|
+
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
attr_reader :file, :num_channels, :size
|
9
|
+
|
10
|
+
def_delegators :@file, :absolute_path, :path
|
11
|
+
|
12
|
+
# @param [::File, String] file_or_path
|
13
|
+
def initialize(file_or_path)
|
14
|
+
@file = file_or_path.kind_of?(::File) ? file_or_path : ::File.new(file_or_path)
|
15
|
+
@sound = RubyAudio::Sound.open(@file)
|
16
|
+
@size = ::File.size(@file)
|
17
|
+
@num_channels = @sound.info.channels
|
18
|
+
end
|
19
|
+
|
20
|
+
# The sample rate of the audio file
|
21
|
+
# @return [Integer]
|
22
|
+
def sample_rate
|
23
|
+
@sample_rate ||= @sound.info.samplerate
|
24
|
+
end
|
25
|
+
|
26
|
+
# Read the audio file into memory
|
27
|
+
# @param [Hash] options
|
28
|
+
# @option options [IO] :logger
|
29
|
+
# @return [Array<Array<Float>>, Array<Float>] File data
|
30
|
+
def read(options = {})
|
31
|
+
if logger = options[:logger]
|
32
|
+
logger.puts("Reading audio file #{@file}")
|
33
|
+
end
|
34
|
+
buffer = RubyAudio::Buffer.float(@size, @num_channels)
|
35
|
+
begin
|
36
|
+
@sound.seek(0)
|
37
|
+
@sound.read(buffer, @size)
|
38
|
+
data = buffer.to_a
|
39
|
+
rescue RubyAudio::Error
|
40
|
+
end
|
41
|
+
logger.puts("Finished reading audio file #{@file}") if logger
|
42
|
+
data
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
class Report
|
4
|
+
|
5
|
+
attr_reader :analysis, :items
|
6
|
+
|
7
|
+
# @param [PulseAnalysis::Analysis] analysis The analysis to report on. Required that analysis has been run (see Analysis#run)
|
8
|
+
def initialize(analysis)
|
9
|
+
@analysis = analysis
|
10
|
+
populate_items
|
11
|
+
end
|
12
|
+
|
13
|
+
# Convert the report to a hash
|
14
|
+
# @return [Hash]
|
15
|
+
def to_h
|
16
|
+
{
|
17
|
+
file: {
|
18
|
+
path: @analysis.sound.audio_file.path.to_s
|
19
|
+
},
|
20
|
+
analysis: @items
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
# Override Object#inspect to not include the large audio data
|
25
|
+
# @return [String]
|
26
|
+
def inspect
|
27
|
+
to_h.inspect
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Usable length of the audio file in the format (MMmSSs)
|
33
|
+
# @return [String]
|
34
|
+
def length_in_formatted_time
|
35
|
+
@length_in_formatted_time ||= Conversion.num_samples_to_formatted_time(@analysis.sound.sample_rate, @analysis.sound.size)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Popualate the report
|
39
|
+
# @return [Array]
|
40
|
+
def populate_items
|
41
|
+
if @analysis.periods.nil?
|
42
|
+
raise "Analysis has not been run yet (use Analysis#run)"
|
43
|
+
else
|
44
|
+
@items = []
|
45
|
+
@items << {
|
46
|
+
key: :sample_rate,
|
47
|
+
description: "Sample rate",
|
48
|
+
value: {
|
49
|
+
unit: "Hertz",
|
50
|
+
value: @analysis.sound.sample_rate
|
51
|
+
}
|
52
|
+
}
|
53
|
+
@items << {
|
54
|
+
key: :length,
|
55
|
+
description: "Length",
|
56
|
+
value: [
|
57
|
+
{
|
58
|
+
unit: "Number of pulses",
|
59
|
+
value: @analysis.num_pulses
|
60
|
+
},
|
61
|
+
{
|
62
|
+
unit: "Time",
|
63
|
+
value: length_in_formatted_time
|
64
|
+
}
|
65
|
+
]
|
66
|
+
}
|
67
|
+
@items << {
|
68
|
+
key: :tempo,
|
69
|
+
description: "Tempo",
|
70
|
+
value: {
|
71
|
+
unit: "BPM",
|
72
|
+
value: @analysis.tempo_bpm.round(4)
|
73
|
+
}
|
74
|
+
}
|
75
|
+
@items << {
|
76
|
+
key: :longest_period,
|
77
|
+
description: "Longest period length",
|
78
|
+
value: [
|
79
|
+
{
|
80
|
+
unit: "Samples",
|
81
|
+
value: @analysis.longest_period
|
82
|
+
},
|
83
|
+
{
|
84
|
+
unit: "ms",
|
85
|
+
value: Conversion.num_samples_to_millis(@analysis.sound.sample_rate, @analysis.longest_period).round(2)
|
86
|
+
}
|
87
|
+
]
|
88
|
+
}
|
89
|
+
@items << {
|
90
|
+
key: :shortest_period,
|
91
|
+
description: "Shortest period length",
|
92
|
+
value: [
|
93
|
+
{
|
94
|
+
unit: "Samples",
|
95
|
+
value: @analysis.shortest_period
|
96
|
+
},
|
97
|
+
{
|
98
|
+
unit: "ms",
|
99
|
+
value: Conversion.num_samples_to_millis(@analysis.sound.sample_rate, @analysis.shortest_period).round(2)
|
100
|
+
}
|
101
|
+
]
|
102
|
+
}
|
103
|
+
@items << {
|
104
|
+
key: :average_period,
|
105
|
+
description: "Average period length",
|
106
|
+
value: [
|
107
|
+
{
|
108
|
+
unit: "Samples",
|
109
|
+
value: @analysis.average_period.round(4)
|
110
|
+
},
|
111
|
+
{
|
112
|
+
unit: "ms",
|
113
|
+
value: Conversion.num_samples_to_millis(@analysis.sound.sample_rate, @analysis.average_period).round(2)
|
114
|
+
}
|
115
|
+
]
|
116
|
+
}
|
117
|
+
@items << {
|
118
|
+
key: :largest_abberation,
|
119
|
+
description: "Largest abberation",
|
120
|
+
value: [
|
121
|
+
{
|
122
|
+
unit: "Samples",
|
123
|
+
value: @analysis.largest_abberation
|
124
|
+
},
|
125
|
+
{
|
126
|
+
unit: "ms",
|
127
|
+
value: Conversion.num_samples_to_millis(@analysis.sound.sample_rate, @analysis.largest_abberation).round(2)
|
128
|
+
}
|
129
|
+
]
|
130
|
+
}
|
131
|
+
@items << {
|
132
|
+
key: :average_abberation,
|
133
|
+
description: "Average abberation",
|
134
|
+
value: [
|
135
|
+
{
|
136
|
+
unit: "Samples",
|
137
|
+
value: @analysis.average_abberation.round(4)
|
138
|
+
},
|
139
|
+
{
|
140
|
+
unit: "ms",
|
141
|
+
value: Conversion.num_samples_to_millis(@analysis.sound.sample_rate, @analysis.average_abberation).round(2)
|
142
|
+
}
|
143
|
+
]
|
144
|
+
}
|
145
|
+
@items
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module PulseAnalysis
|
2
|
+
|
3
|
+
class Sound
|
4
|
+
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
attr_reader :audio_file, :data, :size
|
8
|
+
def_delegators :@audio_file, :num_channels, :sample_rate
|
9
|
+
|
10
|
+
# Load a sound from the given file path
|
11
|
+
# @param [::File, String] file_or_path
|
12
|
+
# @param [Hash] options
|
13
|
+
# @option options [IO] logger
|
14
|
+
# @return [Sound]
|
15
|
+
def self.load(file_or_path, options = {})
|
16
|
+
file = PulseAnalysis::File.new(file_or_path)
|
17
|
+
new(file, options)
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param [PulseAnalysis::File] audio_file
|
21
|
+
# @param [Hash] options
|
22
|
+
# @option options [IO] :logger
|
23
|
+
def initialize(audio_file, options = {})
|
24
|
+
@audio_file = audio_file
|
25
|
+
populate(options)
|
26
|
+
report(options[:logger]) if options[:logger]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Log a report about the sound
|
30
|
+
# @param [IO] logger
|
31
|
+
# @return [Boolean]
|
32
|
+
def report(logger)
|
33
|
+
logger.puts("Sound report for #{@audio_file.file}")
|
34
|
+
logger.puts(" Sample rate: #{@audio_file.sample_rate}")
|
35
|
+
logger.puts(" Channels: #{@audio_file.num_channels}")
|
36
|
+
logger.puts(" File size: #{@audio_file.size}")
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
# Validate that the sound is analyzable
|
41
|
+
# @return [Boolean]
|
42
|
+
def validate_for_analysis
|
43
|
+
if sample_rate < 48000
|
44
|
+
raise "Sample rate must be at least 48000"
|
45
|
+
end
|
46
|
+
true
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
# Populate the sound meta/data
|
52
|
+
# @param [Hash] options
|
53
|
+
# @option options [IO] :logger
|
54
|
+
# @return [Sound]
|
55
|
+
def populate(options = {})
|
56
|
+
@data = @audio_file.read(options)
|
57
|
+
@size = data.size
|
58
|
+
self
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# Pulse Analysis
|
2
|
+
# Measure pulse timing accuracy in an audio file
|
3
|
+
#
|
4
|
+
# (c)2017 Ari Russo
|
5
|
+
# Apache 2.0 License
|
6
|
+
# https://github.com/arirusso/pulse-analysis
|
7
|
+
#
|
8
|
+
|
9
|
+
# libs
|
10
|
+
require "forwardable"
|
11
|
+
require "ruby-audio"
|
12
|
+
|
13
|
+
# modules
|
14
|
+
require "pulse-analysis/conversion"
|
15
|
+
|
16
|
+
# classes
|
17
|
+
require "pulse-analysis/analysis"
|
18
|
+
require "pulse-analysis/audio_data"
|
19
|
+
require "pulse-analysis/file"
|
20
|
+
require "pulse-analysis/report"
|
21
|
+
require "pulse-analysis/sound"
|
22
|
+
|
23
|
+
module PulseAnalysis
|
24
|
+
|
25
|
+
VERSION = "0.0.1"
|
26
|
+
|
27
|
+
# Analyze the given audio file with the given options and generate a report
|
28
|
+
# @param [::File, String] file_or_path File or path to audio file to run analysis on
|
29
|
+
# @param [Hash] options
|
30
|
+
# @option options [Float] :amplitude_threshold Pulses above this amplitude will be analyzed
|
31
|
+
# @option options [Integer] :length_threshold Pulse periods longer than this value will be analyzed
|
32
|
+
# @return [PulseAnalysis::Report]
|
33
|
+
def self.report(file_or_path, options = {})
|
34
|
+
analysis = Analysis.new(file_or_path, options)
|
35
|
+
analysis.run
|
36
|
+
Report.new(analysis)
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
metadata
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pulse-analysis
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ari Russo
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-06-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '10.4'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 10.4.2
|
23
|
+
type: :development
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '10.4'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 10.4.2
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: rspec
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '3.5'
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: 3.5.0
|
43
|
+
type: :development
|
44
|
+
prerelease: false
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - "~>"
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '3.5'
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: 3.5.0
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: ruby-audio
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - "~>"
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '1.6'
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: 1.6.1
|
63
|
+
type: :runtime
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - "~>"
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '1.6'
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: 1.6.1
|
73
|
+
- !ruby/object:Gem::Dependency
|
74
|
+
name: terminal-table
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - "~>"
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '1.8'
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 1.8.0
|
83
|
+
type: :runtime
|
84
|
+
prerelease: false
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.8'
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: 1.8.0
|
93
|
+
- !ruby/object:Gem::Dependency
|
94
|
+
name: tty-spinner
|
95
|
+
requirement: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - "~>"
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0.4'
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 0.4.1
|
103
|
+
type: :runtime
|
104
|
+
prerelease: false
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - "~>"
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0.4'
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 0.4.1
|
113
|
+
description: Measure pulse timing accuracy in an audio file. Use to measure the timing
|
114
|
+
accuracy of drum machines, sequencers and other music electronics. Inspired by the
|
115
|
+
Inner Clock Systems Litmus Test
|
116
|
+
email:
|
117
|
+
- ari.russo@gmail.com
|
118
|
+
executables:
|
119
|
+
- pulse-analysis
|
120
|
+
extensions: []
|
121
|
+
extra_rdoc_files: []
|
122
|
+
files:
|
123
|
+
- LICENSE
|
124
|
+
- README.md
|
125
|
+
- bin/pulse-analysis
|
126
|
+
- lib/pulse-analysis.rb
|
127
|
+
- lib/pulse-analysis/analysis.rb
|
128
|
+
- lib/pulse-analysis/audio_data.rb
|
129
|
+
- lib/pulse-analysis/console.rb
|
130
|
+
- lib/pulse-analysis/console/table.rb
|
131
|
+
- lib/pulse-analysis/conversion.rb
|
132
|
+
- lib/pulse-analysis/file.rb
|
133
|
+
- lib/pulse-analysis/report.rb
|
134
|
+
- lib/pulse-analysis/sound.rb
|
135
|
+
homepage: http://github.com/arirusso/pulse-analysis
|
136
|
+
licenses:
|
137
|
+
- Apache-2.0
|
138
|
+
metadata: {}
|
139
|
+
post_install_message:
|
140
|
+
rdoc_options: []
|
141
|
+
require_paths:
|
142
|
+
- lib
|
143
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - ">="
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '0'
|
148
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 1.3.6
|
153
|
+
requirements: []
|
154
|
+
rubyforge_project: pulse-analysis
|
155
|
+
rubygems_version: 2.6.8
|
156
|
+
signing_key:
|
157
|
+
specification_version: 4
|
158
|
+
summary: Measure pulse timing accuracy in an audio file
|
159
|
+
test_files: []
|