ruby-echonest 0.0.6 → 0.1.1
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/README.rdoc +9 -5
- data/Rakefile +1 -0
- data/lib/echonest/analysis.rb +91 -0
- data/lib/echonest/api.rb +94 -148
- data/lib/echonest/element/bar.rb +1 -4
- data/lib/echonest/element/beat.rb +1 -4
- data/lib/echonest/element/section.rb +3 -2
- data/lib/echonest/element/segment.rb +2 -2
- data/lib/echonest/element/tatum.rb +1 -4
- data/lib/echonest/response.rb +10 -23
- data/lib/echonest/traditional_api_methods.rb +14 -0
- data/lib/echonest/version.rb +1 -1
- data/lib/echonest.rb +3 -2
- data/spec/analysis_spec.rb +98 -0
- data/spec/api_spec.rb +63 -177
- data/spec/fixtures/analysis.json +3 -0
- data/spec/fixtures/profile.json +1 -0
- data/spec/fixtures/profile_failure.json +1 -0
- data/spec/fixtures/profile_unknown.json +1 -0
- data/spec/response_spec.rb +6 -43
- data/spec/track_spec.rb +106 -0
- metadata +43 -27
- data/lib/echonest/element/value_with_confidence.rb +0 -10
- data/spec/fixtures/get_bars.xml +0 -2
- data/spec/fixtures/get_beats.xml +0 -2
- data/spec/fixtures/get_duration.xml +0 -2
- data/spec/fixtures/get_end_of_fade_in.xml +0 -2
- data/spec/fixtures/get_key.xml +0 -2
- data/spec/fixtures/get_loudness.xml +0 -2
- data/spec/fixtures/get_metadata.xml +0 -2
- data/spec/fixtures/get_mode.xml +0 -2
- data/spec/fixtures/get_sections.xml +0 -2
- data/spec/fixtures/get_segments.xml +0 -2
- data/spec/fixtures/get_start_of_fade_out.xml +0 -2
- data/spec/fixtures/get_tatums.xml +0 -2
- data/spec/fixtures/get_tempo.xml +0 -2
- data/spec/fixtures/get_time_signature.xml +0 -2
data/README.rdoc
CHANGED
@@ -12,14 +12,11 @@ A Ruby interface for Echo Nest Developer API
|
|
12
12
|
|
13
13
|
=== Gem Installation
|
14
14
|
|
15
|
-
gem update --system
|
16
|
-
gem install gemcutter
|
17
|
-
gem tumble
|
18
15
|
gem install ruby-echonest
|
19
16
|
|
20
17
|
== Features/Problems
|
21
18
|
|
22
|
-
Only supports the API for Track http://developer.echonest.com/
|
19
|
+
Only supports the API for Track http://developer.echonest.com/docs/v4/track.html
|
23
20
|
|
24
21
|
== Synopsis
|
25
22
|
|
@@ -28,7 +25,14 @@ Only supports the API for Track http://developer.echonest.com/pages/overview
|
|
28
25
|
|
29
26
|
filename = 'foo.mp3'
|
30
27
|
echonest = Echonest('YOUR_API_KEY')
|
31
|
-
|
28
|
+
|
29
|
+
analysis = echonest.track.analysis(filename)
|
30
|
+
beats = analysis.beats
|
31
|
+
segments = analysis.segments
|
32
|
+
|
33
|
+
# traditional way
|
34
|
+
beats = echonest.get_beats(filename)
|
35
|
+
segments = echonest.get_segments(filename)
|
32
36
|
|
33
37
|
== Thanks
|
34
38
|
|
data/Rakefile
CHANGED
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
module Echonest
|
5
|
+
class Analysis
|
6
|
+
def initialize(json)
|
7
|
+
@body = JSON.parse(json)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.new_from_url(url)
|
11
|
+
new(open(url).read)
|
12
|
+
end
|
13
|
+
|
14
|
+
def tempo
|
15
|
+
track_info['tempo']
|
16
|
+
end
|
17
|
+
|
18
|
+
def duration
|
19
|
+
track_info['duration']
|
20
|
+
end
|
21
|
+
|
22
|
+
def end_of_fade_in
|
23
|
+
track_info['end_of_fade_in']
|
24
|
+
end
|
25
|
+
|
26
|
+
def key
|
27
|
+
track_info['key']
|
28
|
+
end
|
29
|
+
|
30
|
+
def loudness
|
31
|
+
track_info['loudness']
|
32
|
+
end
|
33
|
+
|
34
|
+
def mode
|
35
|
+
track_info['mode']
|
36
|
+
end
|
37
|
+
|
38
|
+
def start_of_fade_out
|
39
|
+
track_info['start_of_fade_out']
|
40
|
+
end
|
41
|
+
|
42
|
+
def time_signature
|
43
|
+
track_info['time_signature']
|
44
|
+
end
|
45
|
+
|
46
|
+
def bars
|
47
|
+
@body['bars'].map do |bar|
|
48
|
+
Bar.new(bar['start'], bar['duration'], bar['confidence'])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def beats
|
53
|
+
@body['beats'].map do |beat|
|
54
|
+
Beat.new(beat['start'], beat['duration'], beat['confidence'])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def sections
|
59
|
+
@body['sections'].map do |section|
|
60
|
+
Section.new(section['start'], section['duration'], section['confidence'])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def tatums
|
65
|
+
@body['tatums'].map do |tatum|
|
66
|
+
Tatum.new(tatum['start'], tatum['duration'], tatum['confidence'])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def segments
|
71
|
+
@body['segments'].map do |segment|
|
72
|
+
loudness = Loudness.new(0.0, segment['loudness_start'])
|
73
|
+
max_loudness = Loudness.new(segment['loudness_max_time'], segment['loudness_max'])
|
74
|
+
|
75
|
+
Segment.new(
|
76
|
+
segment['start'],
|
77
|
+
segment['duration'],
|
78
|
+
segment['confidence'],
|
79
|
+
loudness,
|
80
|
+
max_loudness,
|
81
|
+
segment['pitches'],
|
82
|
+
segment['timbre']
|
83
|
+
)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def track_info
|
88
|
+
@body['track']
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/echonest/api.rb
CHANGED
@@ -1,12 +1,15 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
1
2
|
require 'digest/md5'
|
2
3
|
require 'httpclient'
|
4
|
+
require 'json'
|
3
5
|
|
4
6
|
module Echonest
|
5
7
|
class Api
|
6
|
-
|
7
|
-
BASE_URL = 'http://developer.echonest.com/api/'
|
8
|
+
BASE_URL = 'http://developer.echonest.com/api/v4/'
|
8
9
|
USER_AGENT = '%s/%s' % ['ruby-echonest', ::Echonest::VERSION]
|
9
10
|
|
11
|
+
include TraditionalApiMethods
|
12
|
+
|
10
13
|
class Error < StandardError; end
|
11
14
|
|
12
15
|
attr_reader :user_agent
|
@@ -16,182 +19,125 @@ module Echonest
|
|
16
19
|
@user_agent = HTTPClient.new(:agent_name => USER_AGENT)
|
17
20
|
end
|
18
21
|
|
19
|
-
def
|
20
|
-
|
21
|
-
analysis.map do |bar|
|
22
|
-
Bar.new(bar.content.to_f, bar['confidence'].to_f)
|
23
|
-
end
|
24
|
-
end
|
22
|
+
def track
|
23
|
+
ApiMethods::Track.new(self)
|
25
24
|
end
|
26
25
|
|
27
|
-
def
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
end
|
32
|
-
end
|
26
|
+
def build_params(params)
|
27
|
+
params = params.
|
28
|
+
merge(:format => 'json').
|
29
|
+
merge(:api_key => @api_key)
|
33
30
|
end
|
34
31
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
|
32
|
+
def request(name, method, params, file = nil)
|
33
|
+
if file
|
34
|
+
query = build_params(params).sort_by do |param|
|
35
|
+
param[0].to_s
|
36
|
+
end.inject([]) do |m, param|
|
37
|
+
m << [URI.encode(param[0].to_s), URI.encode(param[1])].join('=')
|
38
|
+
end.join('&')
|
39
39
|
|
40
|
-
|
41
|
-
|
42
|
-
max_loudness = Loudness.new(db['time'].to_f, db.content.to_f)
|
43
|
-
else
|
44
|
-
loudness = Loudness.new(db['time'].to_f, db.content.to_f)
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
pitches = segment.find('pitches/pitch').map do |pitch|
|
49
|
-
pitch.content.to_f
|
50
|
-
end
|
40
|
+
uri = URI.join(BASE_URL, name.to_s)
|
41
|
+
uri.query = query
|
51
42
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
end
|
43
|
+
response_body = @user_agent.__send__(
|
44
|
+
method.to_s + '_content',
|
45
|
+
uri,
|
46
|
+
file.read,
|
47
|
+
{
|
48
|
+
'Content-Type' => 'application/octet-stream'
|
49
|
+
})
|
50
|
+
else
|
51
|
+
response_body = @user_agent.__send__(
|
52
|
+
method.to_s + '_content',
|
53
|
+
URI.join(BASE_URL, name.to_s),
|
54
|
+
build_params(params))
|
65
55
|
end
|
66
|
-
end
|
67
56
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
def get_sections(filename)
|
75
|
-
get_analysys(:get_sections, filename) do |analysis|
|
76
|
-
analysis.map do |section|
|
77
|
-
Section.new(
|
78
|
-
section['start'].to_f,
|
79
|
-
section['duration'].to_f
|
80
|
-
)
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
def get_duration(filename)
|
86
|
-
get_analysys(:get_duration, filename) do |analysis|
|
87
|
-
analysis.first.content.to_f
|
57
|
+
response = Response.new(response_body)
|
58
|
+
unless response.success?
|
59
|
+
raise Error.new(response.status.message)
|
88
60
|
end
|
89
|
-
end
|
90
61
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
end
|
62
|
+
response
|
63
|
+
rescue HTTPClient::BadResponseError => e
|
64
|
+
raise Error.new('%s: %s' % [name, e.message])
|
95
65
|
end
|
66
|
+
end
|
96
67
|
|
97
|
-
|
98
|
-
|
99
|
-
|
68
|
+
module ApiMethods
|
69
|
+
class Base
|
70
|
+
def initialize(api)
|
71
|
+
@api = api
|
100
72
|
end
|
101
73
|
end
|
102
74
|
|
103
|
-
|
104
|
-
|
105
|
-
|
75
|
+
class Track < Base
|
76
|
+
def profile(options)
|
77
|
+
@api.request('track/profile',
|
78
|
+
:get,
|
79
|
+
options.merge(:bucket => 'audio_summary'))
|
106
80
|
end
|
107
|
-
end
|
108
81
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
memo
|
114
|
-
end
|
82
|
+
def analyze(options)
|
83
|
+
@api.request('track/analyze',
|
84
|
+
:post,
|
85
|
+
options.merge(:bucket => 'audio_summary'))
|
115
86
|
end
|
116
|
-
end
|
117
87
|
|
118
|
-
|
119
|
-
|
120
|
-
ValueWithConfidence.new(analysis.first.content.to_i, analysis.first['confidence'].to_f)
|
121
|
-
end
|
122
|
-
end
|
88
|
+
def upload(options)
|
89
|
+
options.update(:bucket => 'audio_summary')
|
123
90
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
end
|
128
|
-
end
|
91
|
+
if options.has_key?(:filename)
|
92
|
+
filename = options.delete(:filename)
|
93
|
+
filetype = filename.to_s.match(/\.(mp3|au|ogg)$/)[1]
|
129
94
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
95
|
+
open(filename) do |f|
|
96
|
+
@api.request('track/upload',
|
97
|
+
:post,
|
98
|
+
options.merge(:filetype => filetype),
|
99
|
+
f)
|
100
|
+
end
|
101
|
+
else
|
102
|
+
@api.request('track/upload', :post, options)
|
134
103
|
end
|
135
104
|
end
|
136
|
-
end
|
137
105
|
|
138
|
-
|
139
|
-
|
140
|
-
|
106
|
+
def analysis(filename)
|
107
|
+
analysis_url = analysis_url(filename)
|
108
|
+
Analysis.new_from_url(analysis_url)
|
141
109
|
end
|
142
|
-
end
|
143
110
|
|
144
|
-
|
145
|
-
|
146
|
-
merge(:version => VERSION).
|
147
|
-
merge(:api_key => @api_key)
|
148
|
-
end
|
111
|
+
def analysis_url(filename)
|
112
|
+
md5 = Digest::MD5.hexdigest(open(filename).read)
|
149
113
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
begin
|
161
|
-
response = request(method, :md5 => md5)
|
162
|
-
|
163
|
-
block.call(response)
|
164
|
-
rescue Echonest::Api::Error => e
|
165
|
-
case e.message
|
166
|
-
when /Analysis not ready/
|
167
|
-
sleep 20 # wait for serverside analysis
|
168
|
-
get_trackinfo(method, filename, &block)
|
169
|
-
when 'Invalid parameter: unknown MD5 file hash'
|
170
|
-
upload(filename)
|
171
|
-
sleep 60 # wait for serverside analysis
|
172
|
-
get_trackinfo(method, filename, &block)
|
173
|
-
else
|
174
|
-
raise
|
175
|
-
end
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
def upload(filename)
|
180
|
-
open(filename) do |f|
|
181
|
-
request(:upload, :file => f)
|
182
|
-
end
|
183
|
-
end
|
114
|
+
while true
|
115
|
+
begin
|
116
|
+
response = profile(:md5 => md5)
|
117
|
+
rescue Api::Error => e
|
118
|
+
if e.message =~ /^The Identifier specified does not exist/
|
119
|
+
response = upload(:filename => filename)
|
120
|
+
else
|
121
|
+
raise
|
122
|
+
end
|
123
|
+
end
|
184
124
|
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
125
|
+
case response.body.track.status
|
126
|
+
when 'unknown'
|
127
|
+
upload(:filename => filename)
|
128
|
+
when 'pending'
|
129
|
+
sleep 60
|
130
|
+
when 'complete'
|
131
|
+
return response.body.track.audio_summary.analysis_url
|
132
|
+
when 'error'
|
133
|
+
raise Error.new(response.body.track.status)
|
134
|
+
when 'unavailable'
|
135
|
+
analyze(:md5 => md5)
|
136
|
+
end
|
189
137
|
|
190
|
-
|
191
|
-
|
138
|
+
sleep 5
|
139
|
+
end
|
192
140
|
end
|
193
|
-
|
194
|
-
response
|
195
141
|
end
|
196
142
|
end
|
197
143
|
end
|
data/lib/echonest/element/bar.rb
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
class Segment < Section
|
2
2
|
attr_reader :loudness, :max_loudness, :pitches, :timbre
|
3
3
|
|
4
|
-
def initialize(start, duration, loudness, max_loudness, pitches, timbre)
|
5
|
-
super(start, duration)
|
4
|
+
def initialize(start, duration, confidence, loudness, max_loudness, pitches, timbre)
|
5
|
+
super(start, duration, confidence)
|
6
6
|
|
7
7
|
@loudness = loudness
|
8
8
|
@max_loudness = max_loudness
|
data/lib/echonest/response.rb
CHANGED
@@ -1,23 +1,24 @@
|
|
1
|
-
require
|
1
|
+
require 'json'
|
2
|
+
require 'hashie'
|
2
3
|
|
3
4
|
module Echonest
|
4
5
|
class Response
|
5
|
-
attr_reader :
|
6
|
+
attr_reader :json
|
6
7
|
|
7
8
|
def initialize(body)
|
8
|
-
@
|
9
|
+
@json = Hashie::Mash.new(JSON.parse(body))
|
9
10
|
end
|
10
11
|
|
11
12
|
def status
|
12
|
-
@status ||= Status.new(
|
13
|
+
@status ||= Status.new(body)
|
13
14
|
end
|
14
15
|
|
15
16
|
def success?
|
16
17
|
status.code == Status::SUCCESS
|
17
18
|
end
|
18
19
|
|
19
|
-
def
|
20
|
-
|
20
|
+
def body
|
21
|
+
json.response
|
21
22
|
end
|
22
23
|
|
23
24
|
class Status
|
@@ -31,23 +32,9 @@ module Echonest
|
|
31
32
|
|
32
33
|
attr_reader :code, :message
|
33
34
|
|
34
|
-
def initialize(
|
35
|
-
@code =
|
36
|
-
@message =
|
37
|
-
end
|
38
|
-
end
|
39
|
-
|
40
|
-
class Query
|
41
|
-
def initialize(xml)
|
42
|
-
@parameters = {}
|
43
|
-
|
44
|
-
xml.find('/response/query/parameter').each do |parameter|
|
45
|
-
@parameters[parameter['name'].to_sym] = parameter.content
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
def [](parameter_name)
|
50
|
-
@parameters[parameter_name]
|
35
|
+
def initialize(response_body)
|
36
|
+
@code = response_body.status.code
|
37
|
+
@message = response_body.status.message
|
51
38
|
end
|
52
39
|
end
|
53
40
|
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Echonest
|
2
|
+
module TraditionalApiMethods
|
3
|
+
def self.included(c)
|
4
|
+
%w/tempo duration end_of_fade_in key loudness mode start_of_fade_out time_signature bars beats sections tatums segments/.
|
5
|
+
each do |method|
|
6
|
+
c.module_eval %Q{
|
7
|
+
def get_%s(filename)
|
8
|
+
track.analysis(filename).%s
|
9
|
+
end
|
10
|
+
} % [method, method]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/echonest/version.rb
CHANGED
data/lib/echonest.rb
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
require 'echonest/version'
|
2
|
+
require 'echonest/traditional_api_methods'
|
2
3
|
require 'echonest/api'
|
4
|
+
require 'echonest/analysis'
|
3
5
|
require 'echonest/response'
|
4
|
-
require 'echonest/element/
|
6
|
+
require 'echonest/element/section'
|
5
7
|
require 'echonest/element/bar'
|
6
8
|
require 'echonest/element/beat'
|
7
|
-
require 'echonest/element/section'
|
8
9
|
require 'echonest/element/segment'
|
9
10
|
require 'echonest/element/loudness'
|
10
11
|
require 'echonest/element/tatum'
|
@@ -0,0 +1,98 @@
|
|
1
|
+
$:.unshift File.dirname(__FILE__)
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
include SpecHelper
|
6
|
+
|
7
|
+
describe Echonest::Analysis do
|
8
|
+
before do
|
9
|
+
@analysis = Echonest::Analysis.new(open(fixture('analysis.json')).read)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should have beats" do
|
13
|
+
beats = @analysis.beats
|
14
|
+
|
15
|
+
beats.size.should eql(324)
|
16
|
+
beats.first.start.should eql(0.27661)
|
17
|
+
beats.first.duration.should eql(0.36476)
|
18
|
+
beats.first.confidence.should eql(0.468)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should have segments" do
|
22
|
+
segments = @analysis.segments
|
23
|
+
segment = segments.first
|
24
|
+
|
25
|
+
segments.size.should eql(274)
|
26
|
+
segment.start.should eql(0.0)
|
27
|
+
segment.duration.should eql(0.43909)
|
28
|
+
segment.confidence.should eql(1.0)
|
29
|
+
segment.loudness.time.should eql(0.0)
|
30
|
+
segment.loudness.value.should eql(-60.0)
|
31
|
+
segment.max_loudness.time.should eql(0.11238)
|
32
|
+
segment.max_loudness.value.should eql(-33.563)
|
33
|
+
segment.pitches.size.should eql(12)
|
34
|
+
segment.pitches.first.should eql(0.138)
|
35
|
+
segment.timbre.size.should eql(12)
|
36
|
+
segment.timbre.first.should eql(11.525)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should have bars" do
|
40
|
+
bars = @analysis.bars
|
41
|
+
|
42
|
+
bars.size.should eql(80)
|
43
|
+
bars.first.start.should eql(1.00704)
|
44
|
+
bars.first.duration.should eql(1.48532)
|
45
|
+
bars.first.confidence.should eql(0.188)
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should have tempo" do
|
49
|
+
@analysis.tempo.should eql(168.460)
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should have sections" do
|
53
|
+
sections = @analysis.sections
|
54
|
+
section = sections.first
|
55
|
+
|
56
|
+
sections.size.should eql(7)
|
57
|
+
section.start.should eql(0.0)
|
58
|
+
section.duration.should eql(20.04271)
|
59
|
+
section.confidence.should eql(1.0)
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should have durtion" do
|
63
|
+
@analysis.duration.should eql(120.68526)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should have end of fade in" do
|
67
|
+
@analysis.end_of_fade_in.should eql(0.0)
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should have start of fade out" do
|
71
|
+
@analysis.start_of_fade_out.should eql(113.557)
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should have key" do
|
75
|
+
@analysis.key.should eql(7)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should have loudness" do
|
79
|
+
@analysis.loudness.should eql(-19.140)
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should have mode" do
|
83
|
+
@analysis.mode.should eql(1)
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should have time signature" do
|
87
|
+
@analysis.time_signature.should eql(4)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "should have tatums" do
|
91
|
+
tatums = @analysis.tatums
|
92
|
+
|
93
|
+
tatums.size.should eql(648)
|
94
|
+
tatums.first.start.should eql(0.09469)
|
95
|
+
tatums.first.duration.should eql(0.18193)
|
96
|
+
tatums.first.confidence.should eql(0.286)
|
97
|
+
end
|
98
|
+
end
|