music-transcription 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.document +3 -0
- data/.gitignore +7 -0
- data/.rspec +1 -0
- data/.yardopts +1 -0
- data/ChangeLog.rdoc +4 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +28 -0
- data/Rakefile +54 -0
- data/bin/transcribe +176 -0
- data/lib/music-transcription.rb +20 -0
- data/lib/music-transcription/arrangement.rb +31 -0
- data/lib/music-transcription/instrument_config.rb +38 -0
- data/lib/music-transcription/interval.rb +66 -0
- data/lib/music-transcription/link.rb +115 -0
- data/lib/music-transcription/note.rb +156 -0
- data/lib/music-transcription/part.rb +128 -0
- data/lib/music-transcription/pitch.rb +297 -0
- data/lib/music-transcription/pitch_constants.rb +204 -0
- data/lib/music-transcription/profile.rb +105 -0
- data/lib/music-transcription/program.rb +136 -0
- data/lib/music-transcription/score.rb +122 -0
- data/lib/music-transcription/tempo.rb +44 -0
- data/lib/music-transcription/transition.rb +71 -0
- data/lib/music-transcription/value_change.rb +85 -0
- data/lib/music-transcription/version.rb +7 -0
- data/music-transcription.gemspec +36 -0
- data/samples/arrangements/glissando_test.yml +71 -0
- data/samples/arrangements/hip.yml +952 -0
- data/samples/arrangements/instrument_test.yml +119 -0
- data/samples/arrangements/legato_test.yml +237 -0
- data/samples/arrangements/make_glissando_test.rb +27 -0
- data/samples/arrangements/make_hip.rb +75 -0
- data/samples/arrangements/make_instrument_test.rb +34 -0
- data/samples/arrangements/make_legato_test.rb +37 -0
- data/samples/arrangements/make_missed_connection.rb +72 -0
- data/samples/arrangements/make_portamento_test.rb +27 -0
- data/samples/arrangements/make_slur_test.rb +37 -0
- data/samples/arrangements/make_song1.rb +84 -0
- data/samples/arrangements/make_song2.rb +69 -0
- data/samples/arrangements/missed_connection.yml +481 -0
- data/samples/arrangements/portamento_test.yml +71 -0
- data/samples/arrangements/slur_test.yml +237 -0
- data/samples/arrangements/song1.yml +640 -0
- data/samples/arrangements/song2.yml +429 -0
- data/spec/instrument_config_spec.rb +47 -0
- data/spec/interval_spec.rb +38 -0
- data/spec/link_spec.rb +22 -0
- data/spec/musicality_spec.rb +7 -0
- data/spec/note_spec.rb +65 -0
- data/spec/part_spec.rb +87 -0
- data/spec/pitch_spec.rb +139 -0
- data/spec/profile_spec.rb +24 -0
- data/spec/program_spec.rb +55 -0
- data/spec/score_spec.rb +55 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/transition_spec.rb +13 -0
- data/spec/value_change_spec.rb +19 -0
- metadata +239 -0
data/.document
ADDED
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--colour
|
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup rdoc --title "music-transcription Documentation" --protected
|
data/ChangeLog.rdoc
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2012 James Tunnell
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
= music-transcription
|
2
|
+
|
3
|
+
* {Homepage}[https://github.com/jamestunnell/music-transcription]
|
4
|
+
* {Issues}[https://github.com/jamestunnell/music-transcription/issues]
|
5
|
+
* {Documentation}[http://rubydoc.info/gems/music-transcription/frames]
|
6
|
+
* {Email}[mailto:jamestunnell@lavabit.com]
|
7
|
+
|
8
|
+
== Description
|
9
|
+
|
10
|
+
Classes for representing music features, like pitch, note, dynamic, tempo, etc.
|
11
|
+
|
12
|
+
== Features
|
13
|
+
|
14
|
+
== Examples
|
15
|
+
|
16
|
+
require 'music-transcription'
|
17
|
+
|
18
|
+
== Requirements
|
19
|
+
|
20
|
+
== Install
|
21
|
+
|
22
|
+
$ gem install music-transcription
|
23
|
+
|
24
|
+
== Copyright
|
25
|
+
|
26
|
+
Copyright (c) 2012 James Tunnell
|
27
|
+
|
28
|
+
See LICENSE.txt for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'bundler'
|
7
|
+
rescue LoadError => e
|
8
|
+
warn e.message
|
9
|
+
warn "Run `gem install bundler` to install Bundler."
|
10
|
+
exit -1
|
11
|
+
end
|
12
|
+
|
13
|
+
begin
|
14
|
+
Bundler.setup(:development)
|
15
|
+
rescue Bundler::BundlerError => e
|
16
|
+
warn e.message
|
17
|
+
warn "Run `bundle install` to install missing gems."
|
18
|
+
exit e.status_code
|
19
|
+
end
|
20
|
+
|
21
|
+
require 'rake'
|
22
|
+
|
23
|
+
require 'rspec/core/rake_task'
|
24
|
+
RSpec::Core::RakeTask.new
|
25
|
+
|
26
|
+
task :test => :spec
|
27
|
+
task :default => :spec
|
28
|
+
|
29
|
+
require "bundler/gem_tasks"
|
30
|
+
|
31
|
+
require 'yard'
|
32
|
+
YARD::Rake::YardocTask.new
|
33
|
+
task :doc => :yard
|
34
|
+
|
35
|
+
task :make_samples do
|
36
|
+
current_dir = Dir.getwd
|
37
|
+
samples_dir = File.join(File.dirname(__FILE__), 'samples')
|
38
|
+
Dir.chdir samples_dir
|
39
|
+
|
40
|
+
samples = []
|
41
|
+
Dir.glob('**/make*.rb') do |file|
|
42
|
+
samples.push File.expand_path(file)
|
43
|
+
end
|
44
|
+
|
45
|
+
samples.each do |sample|
|
46
|
+
dirname = File.dirname(sample)
|
47
|
+
filename = File.basename(sample)
|
48
|
+
|
49
|
+
Dir.chdir dirname
|
50
|
+
ruby filename
|
51
|
+
end
|
52
|
+
|
53
|
+
Dir.chdir current_dir
|
54
|
+
end
|
data/bin/transcribe
ADDED
@@ -0,0 +1,176 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'micro-optparse'
|
4
|
+
require 'pry'
|
5
|
+
require 'spcore'
|
6
|
+
require 'wavefile'
|
7
|
+
require 'music-transcription'
|
8
|
+
require 'yaml'
|
9
|
+
|
10
|
+
include Music::Transcription
|
11
|
+
|
12
|
+
options = Parser.new do |p|
|
13
|
+
p.banner = <<-END
|
14
|
+
Transcribe notes from an audio file.
|
15
|
+
|
16
|
+
Usage:
|
17
|
+
transcribe [options] <audio_file>
|
18
|
+
|
19
|
+
Notes:
|
20
|
+
Output directory must already exist.
|
21
|
+
FFT actual_size must be a power of two.
|
22
|
+
Delta (sec) must be > 0.
|
23
|
+
Threshold must be >= 0.
|
24
|
+
|
25
|
+
Options:
|
26
|
+
END
|
27
|
+
p.version = "0.1"
|
28
|
+
p.option :outdir, "set output directory", :default => "./", :value_satisfies => lambda { |path| Dir.exist? path }
|
29
|
+
p.option :freq_res, "set min frequency (Hz) resolution in Hz", :default => 1.0, :value_satisfies => lambda { |mfr| mfr > 0 }
|
30
|
+
p.option :time_res, "set time (sec) between FFT samples", :default => 0.01, :value_satisfies => lambda { |dt| dt > 0 }
|
31
|
+
p.option :threshold, "set theshold (minimum signal energy to run FFT)", :default => 0.5, :value_satisfies => lambda { |t| t >= 0 }
|
32
|
+
p.option :min_freq, "set minimum frequency to consider during harmonic series analysis.", :default => PITCHES.first.freq, :value_satisfies => lambda { |f| f > 0 }
|
33
|
+
p.option :verbose, "is verbose?", :default => false
|
34
|
+
end.process!
|
35
|
+
|
36
|
+
outdir = File.expand_path(options[:outdir])
|
37
|
+
freq_res = options[:freq_res]
|
38
|
+
time_res = options[:time_res]
|
39
|
+
threshold = options[:threshold]
|
40
|
+
verbose = options[:verbose]
|
41
|
+
min_freq = options[:min_freq]
|
42
|
+
|
43
|
+
TIME_RESOLUTION = time_res
|
44
|
+
THRESHOLD = threshold
|
45
|
+
FREQ_RESOLUTION = freq_res
|
46
|
+
|
47
|
+
def force_power_of_two size
|
48
|
+
power_of_two = Math::log2(size)
|
49
|
+
if power_of_two.floor != power_of_two # input size is not an even power of two
|
50
|
+
size = 2**(power_of_two.to_i() + 1)
|
51
|
+
end
|
52
|
+
return size
|
53
|
+
end
|
54
|
+
|
55
|
+
ARGV.each do |filename|
|
56
|
+
|
57
|
+
if !File.exist?(filename)
|
58
|
+
puts "Could not find file #{filename}, skipping."
|
59
|
+
next
|
60
|
+
end
|
61
|
+
|
62
|
+
if verbose
|
63
|
+
puts
|
64
|
+
end
|
65
|
+
|
66
|
+
outfile = File.basename(filename, ".*") + ".yml"
|
67
|
+
outpath = File.join(outdir, outfile)
|
68
|
+
|
69
|
+
notes = []
|
70
|
+
|
71
|
+
WaveFile::Reader.new(filename) do |reader|
|
72
|
+
print "Transcribing #{File.basename(filename)} -> #{outfile} 0.0%"
|
73
|
+
|
74
|
+
if reader.format.channels == 1
|
75
|
+
samples = reader.read(reader.total_sample_frames).samples
|
76
|
+
else
|
77
|
+
puts "Multi-channel audo files not supported yet. Skipping."
|
78
|
+
# TODO handl multi-channel audio
|
79
|
+
break
|
80
|
+
end
|
81
|
+
signal = SPCore::Signal.new(:data => samples, :sample_rate => reader.format.sample_rate)
|
82
|
+
|
83
|
+
ideal_size = (TIME_RESOLUTION * signal.sample_rate).to_i
|
84
|
+
fft_size = reader.format.sample_rate / FREQ_RESOLUTION
|
85
|
+
fft_size = force_power_of_two fft_size
|
86
|
+
|
87
|
+
# more samples are in the chunk than needed by the FFT. Make the FFT size
|
88
|
+
# greater or equal to chunk size.
|
89
|
+
if ideal_size > fft_size
|
90
|
+
fft_size = force_power_of_two ideal_size
|
91
|
+
end
|
92
|
+
|
93
|
+
i = 0
|
94
|
+
while(i < signal.size)
|
95
|
+
print "\b" * 6
|
96
|
+
print "%5.1f%%" % (100 * i / signal.size)
|
97
|
+
|
98
|
+
actual_size = ideal_size
|
99
|
+
if (actual_size + i) > signal.size
|
100
|
+
actual_size = signal.size - i
|
101
|
+
end
|
102
|
+
t = i / signal.sample_rate.to_f
|
103
|
+
puts t if verbose
|
104
|
+
|
105
|
+
chunk = signal.subset i...(i + actual_size)
|
106
|
+
duration_sec = actual_size / signal.sample_rate.to_f
|
107
|
+
|
108
|
+
if chunk.energy > THRESHOLD
|
109
|
+
|
110
|
+
|
111
|
+
window = SPCore::BlackmanWindow.new(chunk.size)
|
112
|
+
chunk.multiply! window.data
|
113
|
+
|
114
|
+
remaining_before = (fft_size - chunk.size) / 2
|
115
|
+
remaining_after = fft_size - chunk.size - remaining_before
|
116
|
+
|
117
|
+
if remaining_before > 0
|
118
|
+
chunk.prepend! Array.new(remaining_before, 0)
|
119
|
+
end
|
120
|
+
|
121
|
+
if remaining_after > 0
|
122
|
+
chunk.append! Array.new(remaining_after, 0)
|
123
|
+
end
|
124
|
+
|
125
|
+
series = chunk.harmonic_series(:min_freq => min_freq)
|
126
|
+
intervals = []
|
127
|
+
if series.nil?
|
128
|
+
if series.any?
|
129
|
+
fundamental = series.min
|
130
|
+
pitch = Pitch.make_from_freq fundamental
|
131
|
+
intervals.push Interval.new(:pitch => pitch)
|
132
|
+
end
|
133
|
+
notes.push Note.new(:duration => duration_sec, :intervals => intervals)
|
134
|
+
else
|
135
|
+
notes.push Note.new(:duration => duration_sec, :intervals => [])
|
136
|
+
end
|
137
|
+
|
138
|
+
i += ideal_size
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
new_notes = []
|
143
|
+
|
144
|
+
# simplify the part
|
145
|
+
notes.each_index do |i|
|
146
|
+
note = notes[i]
|
147
|
+
|
148
|
+
if new_notes.any?
|
149
|
+
prev_note = new_notes.last
|
150
|
+
|
151
|
+
if prev_note.intervals.any? and note.intervals.any?
|
152
|
+
if prev_note.intervals == note.intervals
|
153
|
+
prev_note.duration += note.duration
|
154
|
+
else
|
155
|
+
prev_note.intervals.first.link = slur(note.intervals.first.pitch)
|
156
|
+
new_notes.push note
|
157
|
+
end
|
158
|
+
elsif prev_note.intervals.empty? and note.intervals.empty?
|
159
|
+
prev_note.duration += note.duration
|
160
|
+
else
|
161
|
+
new_notes.push note
|
162
|
+
end
|
163
|
+
else
|
164
|
+
new_notes.push note
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
part = Part.new(:notes => new_notes)
|
169
|
+
yaml = part.make_hash.to_yaml
|
170
|
+
|
171
|
+
File.open(outpath, 'w') do |file|
|
172
|
+
file.write yaml
|
173
|
+
end
|
174
|
+
|
175
|
+
puts
|
176
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'hashmake'
|
2
|
+
|
3
|
+
# basic core classes
|
4
|
+
require 'music-transcription/version'
|
5
|
+
|
6
|
+
# code for transcribing (representing) music
|
7
|
+
require 'music-transcription/pitch'
|
8
|
+
require 'music-transcription/pitch_constants'
|
9
|
+
require 'music-transcription/link'
|
10
|
+
require 'music-transcription/interval'
|
11
|
+
require 'music-transcription/note'
|
12
|
+
require 'music-transcription/transition'
|
13
|
+
require 'music-transcription/value_change'
|
14
|
+
require 'music-transcription/profile'
|
15
|
+
require 'music-transcription/part'
|
16
|
+
require 'music-transcription/program'
|
17
|
+
require 'music-transcription/tempo'
|
18
|
+
require 'music-transcription/score'
|
19
|
+
require 'music-transcription/instrument_config'
|
20
|
+
require 'music-transcription/arrangement'
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Contains a Score object, and also instrument configurations to be assigned to
|
5
|
+
# score parts.
|
6
|
+
class Arrangement
|
7
|
+
include Hashmake::HashMakeable
|
8
|
+
|
9
|
+
# specifies which hashed args can be used for initialize.
|
10
|
+
ARG_SPECS = {
|
11
|
+
:score => arg_spec(:reqd => true, :type => Score),
|
12
|
+
:instrument_configs => arg_spec_hash(:reqd => false, :type => InstrumentConfig),
|
13
|
+
}
|
14
|
+
|
15
|
+
attr_reader :score, :instrument_configs
|
16
|
+
|
17
|
+
def initialize args
|
18
|
+
hash_make args, Arrangement::ARG_SPECS
|
19
|
+
end
|
20
|
+
|
21
|
+
# Assign a new score.
|
22
|
+
# @param [Score] score The new score.
|
23
|
+
# @raise [ArgumentError] if score is not a Score.
|
24
|
+
def score= score
|
25
|
+
Arrangement::ARG_SPECS[:score].validate_value score
|
26
|
+
@score = score
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Contains all the information needed to create the instrument plugin, configure
|
5
|
+
# initial settings, and any settings changes.
|
6
|
+
#
|
7
|
+
# @author James Tunnell
|
8
|
+
class InstrumentConfig
|
9
|
+
include Hashmake::HashMakeable
|
10
|
+
|
11
|
+
attr_reader :plugin_name, :initial_settings, :setting_changes
|
12
|
+
|
13
|
+
# hashed-arg specs (for hash-makeable idiom)
|
14
|
+
ARG_SPECS = {
|
15
|
+
:plugin_name => arg_spec(:reqd => true, :type => String),
|
16
|
+
:initial_settings => arg_spec(:reqd => false, :default => ->(){ {} }, :validator => ->(a){ a.is_a?(String) || a.is_a?(Array) || a.is_a?(Hash)} ),
|
17
|
+
:setting_changes => arg_spec_hash(:reqd => false, :type => Hash)
|
18
|
+
}
|
19
|
+
|
20
|
+
# A new instance of InstrumentConfig.
|
21
|
+
# @param [Hash] args Hashed arguments. Required key is :plugin_name (String).
|
22
|
+
# Optional key is :initial_settings and setting_changes.
|
23
|
+
def initialize args={}
|
24
|
+
hash_make args, InstrumentConfig::ARG_SPECS
|
25
|
+
|
26
|
+
@setting_changes.each do |offset, hash|
|
27
|
+
hash.each do |key, val|
|
28
|
+
# replace plain values with immediate value changes
|
29
|
+
unless val.is_a?(ValueChange)
|
30
|
+
hash[keys] = immediate_change(val)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Describes a pitch (for one note) that can be linked to a pitch in another note.
|
5
|
+
#
|
6
|
+
# @author James Tunnell
|
7
|
+
#
|
8
|
+
# @!attribute [rw] pitch
|
9
|
+
# @return [Pitch] The pitch of the note.
|
10
|
+
#
|
11
|
+
# @!attribute [rw] link
|
12
|
+
# @return [Link] Shows how the current note is related to a following note.
|
13
|
+
#
|
14
|
+
class Interval
|
15
|
+
include Hashmake::HashMakeable
|
16
|
+
attr_reader :pitch, :link
|
17
|
+
|
18
|
+
# hashed-arg specs (for hash-makeable idiom)
|
19
|
+
ARG_SPECS = {
|
20
|
+
:pitch => arg_spec(:type => Pitch, :reqd => true),
|
21
|
+
:link => arg_spec(:type => Link, :reqd => false, :default => ->(){ Link.new } ),
|
22
|
+
}
|
23
|
+
|
24
|
+
def initialize args
|
25
|
+
hash_make args
|
26
|
+
end
|
27
|
+
|
28
|
+
# Return true if the @link relationship is not NONE.
|
29
|
+
def linked?
|
30
|
+
@link.relationship != Link::RELATIONSHIP_NONE
|
31
|
+
end
|
32
|
+
|
33
|
+
# Compare the equality of another Interval object.
|
34
|
+
def == other
|
35
|
+
return (@pitch == other.pitch) && (@link == other.link)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Set the note pitch.
|
39
|
+
# @param [Pitch] pitch The pitch to use.
|
40
|
+
# @raise [ArgumentError] if pitch is not a Pitch.
|
41
|
+
def pitch= pitch
|
42
|
+
ARG_SPECS[:pitch].validate_value pitch
|
43
|
+
@pitch = pitch
|
44
|
+
end
|
45
|
+
|
46
|
+
# Setup the relationship to a following note.
|
47
|
+
# @param [Link] link The Link object to assign.
|
48
|
+
def link= link
|
49
|
+
ARG_SPECS[:link].validate_value link
|
50
|
+
@link = link
|
51
|
+
end
|
52
|
+
|
53
|
+
# Produce an identical Note object.
|
54
|
+
def clone
|
55
|
+
Interval.new(:pitch => @pitch, :link => @link.clone)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
module_function
|
60
|
+
|
61
|
+
def interval pitch, link = Link.new
|
62
|
+
Interval.new(:pitch => pitch, :link => link)
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|