pulse-analysis 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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)
@@ -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,8 @@
1
+ require "pulse-analysis/console/table"
2
+
3
+ module PulseAnalysis
4
+
5
+ module Console
6
+ end
7
+
8
+ 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: []