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
@@ -0,0 +1,115 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Defines a relationship (tie, slur, legato, etc.) to a note with a certain pitch.
|
5
|
+
#
|
6
|
+
# @author James Tunnell
|
7
|
+
#
|
8
|
+
# @!attribute [rw] target_pitch
|
9
|
+
# @return [Pitch] The pitch of the note which is being connected to.
|
10
|
+
#
|
11
|
+
# @!attribute [rw] relationship
|
12
|
+
# @return [Symbol] The relationship between the current note and a consecutive
|
13
|
+
# note. Valid values are RELATIONSHIP_NONE, RELATIONSHIP_TIE,
|
14
|
+
# RELATIONSHIP_SLUR, RELATIONSHIP_LEGATO, RELATIONSHIP_GLISSANDO,
|
15
|
+
# and RELATIONSHIP_PORTAMENTO.
|
16
|
+
#
|
17
|
+
class Link
|
18
|
+
include Hashmake::HashMakeable
|
19
|
+
|
20
|
+
# no relationship with the following note
|
21
|
+
RELATIONSHIP_NONE = :none
|
22
|
+
# tie to the following note
|
23
|
+
RELATIONSHIP_TIE = :tie
|
24
|
+
# play notes continuously and don't rearticulate
|
25
|
+
RELATIONSHIP_SLUR = :slur
|
26
|
+
# play notes continuously and do rearticulate
|
27
|
+
RELATIONSHIP_LEGATO = :legato
|
28
|
+
# play an uninterrupted slide through a series of consecutive tones to the next note.
|
29
|
+
RELATIONSHIP_GLISSANDO = :glissando
|
30
|
+
# play an uninterrupted glide to the next note.
|
31
|
+
RELATIONSHIP_PORTAMENTO = :portamento
|
32
|
+
|
33
|
+
# a list of valid note relationships
|
34
|
+
RELATIONSHIPS = [
|
35
|
+
RELATIONSHIP_NONE,
|
36
|
+
RELATIONSHIP_TIE,
|
37
|
+
RELATIONSHIP_SLUR,
|
38
|
+
RELATIONSHIP_LEGATO,
|
39
|
+
RELATIONSHIP_GLISSANDO,
|
40
|
+
RELATIONSHIP_PORTAMENTO
|
41
|
+
]
|
42
|
+
|
43
|
+
# hashed-arg specs (for hash-makeable idiom)
|
44
|
+
ARG_SPECS = {
|
45
|
+
:target_pitch => arg_spec(:reqd => false, :type => Pitch, :default => ->(){ Pitch.new }),
|
46
|
+
:relationship => arg_spec(:reqd => false, :type => Symbol, :default => RELATIONSHIP_NONE, :validator => ->(a){ RELATIONSHIPS.include?(a)}),
|
47
|
+
}
|
48
|
+
|
49
|
+
attr_reader :target_pitch, :relationship
|
50
|
+
|
51
|
+
# A new instance of Link.
|
52
|
+
# @param [Hash] args Hashed arguments. See ARG_SPECS for details about valid keys.
|
53
|
+
def initialize args={}
|
54
|
+
hash_make args
|
55
|
+
end
|
56
|
+
|
57
|
+
# Produce an identical Link object.
|
58
|
+
def clone
|
59
|
+
Link.new(:target_pitch => @target_pitch.clone, :relationship => @relationship)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Compare equality of two Link objects.
|
63
|
+
def ==(other)
|
64
|
+
return (@target_pitch == other.target_pitch) && (@relationship == other.relationship)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Set the pitch of the note being connected to.
|
68
|
+
# @param [Pitch] target_pitch The pitch of the note being connected to.
|
69
|
+
# @raise [ArgumentError] if target_pitch is not a Pitch.
|
70
|
+
def target_pitch= target_pitch
|
71
|
+
ARG_SPECS[:target_pitch].validate_value target_pitch
|
72
|
+
@target_pitch = target_pitch
|
73
|
+
end
|
74
|
+
|
75
|
+
# Set the note relationship.
|
76
|
+
# @param [Symbol] relationship The relationship of the note to the following
|
77
|
+
# note (if applicable). Valid relationship are given by the
|
78
|
+
# RELATIONSHIPS constant.
|
79
|
+
# @raise [ArgumentError] if relationship is not a valid relationship.
|
80
|
+
def relationship= relationship
|
81
|
+
ARG_SPECS[:relationship].validate_value relationship
|
82
|
+
@relationship = relationship
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
|
87
|
+
module_function
|
88
|
+
|
89
|
+
# helper method to create a Link object with GLISSANDO relationship.
|
90
|
+
def glissando pitch
|
91
|
+
Link.new(:target_pitch => pitch, :relationship => Link::RELATIONSHIP_GLISSANDO)
|
92
|
+
end
|
93
|
+
|
94
|
+
# helper method to create a Link object with LEGATO relationship.
|
95
|
+
def legato pitch
|
96
|
+
Link.new(:target_pitch => pitch, :relationship => Link::RELATIONSHIP_LEGATO)
|
97
|
+
end
|
98
|
+
|
99
|
+
# helper method to create a Link object with PORTAMENTO relationship.
|
100
|
+
def portamento pitch
|
101
|
+
Link.new(:target_pitch => pitch, :relationship => Link::RELATIONSHIP_PORTAMENTO)
|
102
|
+
end
|
103
|
+
|
104
|
+
# helper method to create a Link object with SLUR relationship.
|
105
|
+
def slur pitch
|
106
|
+
Link.new(:target_pitch => pitch, :relationship => Link::RELATIONSHIP_SLUR)
|
107
|
+
end
|
108
|
+
|
109
|
+
# helper method to create a Link object with TIE relationship.
|
110
|
+
def tie pitch
|
111
|
+
Link.new(:target_pitch => pitch, :relationship => Link::RELATIONSHIP_TIE)
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Abstraction of a musical note. The note can contain multiple intervals
|
5
|
+
# (at different pitches). Each interval can also contain a link to an interval
|
6
|
+
# in a following note. Contains values for attack, sustain, and separation,
|
7
|
+
# which will be used to form the envelope profile for the note.
|
8
|
+
#
|
9
|
+
# @author James Tunnell
|
10
|
+
#
|
11
|
+
# @!attribute [rw] duration
|
12
|
+
# @return [Numeric] The duration (in, say note length or time), greater than 0.0.
|
13
|
+
#
|
14
|
+
# @!attribute [rw] intervals
|
15
|
+
# @return [Numeric] The intervals that define which pitches are part of the
|
16
|
+
# note and can link to intervals in a following note.
|
17
|
+
#
|
18
|
+
# @!attribute [rw] attack
|
19
|
+
# @return [Numeric] The amount of attack, from 0.0 (less) to 1.0 (more).
|
20
|
+
# Attack controls how quickly a note's loudness increases
|
21
|
+
# at the start.
|
22
|
+
#
|
23
|
+
# @!attribute [rw] sustain
|
24
|
+
# @return [Numeric] The amount of sustain, from 0.0 (less) to 1.0 (more).
|
25
|
+
# Sustain controls how much the note's loudness is
|
26
|
+
# sustained after the attack.
|
27
|
+
#
|
28
|
+
# @!attribute [rw] separation
|
29
|
+
# @return [Numeric] Shift the note release towards or away the beginning
|
30
|
+
# of the note. From 0.0 (towards end of the note) to
|
31
|
+
# 1.0 (towards beginning of the note).
|
32
|
+
#
|
33
|
+
class Note
|
34
|
+
include Hashmake::HashMakeable
|
35
|
+
attr_reader :duration, :intervals, :sustain, :attack, :separation
|
36
|
+
|
37
|
+
# hashed-arg specs (for hash-makeable idiom)
|
38
|
+
ARG_SPECS = {
|
39
|
+
:duration => arg_spec(:type => Numeric, :reqd => true, :validator => ->(a){ a > 0 } ),
|
40
|
+
:intervals => arg_spec_array(:type => Interval, :reqd => false),
|
41
|
+
:sustain => arg_spec(:type => Numeric, :reqd => false, :validator => ->(a){ a.between?(0.0,1.0)}, :default => 0.5),
|
42
|
+
:attack => arg_spec(:type => Numeric, :reqd => false, :validator => ->(a){ a.between?(0.0,1.0)}, :default => 0.5),
|
43
|
+
:separation => arg_spec(:type => Numeric, :reqd => false, :validator => ->(a){ a.between?(0.0,1.0)}, :default => 0.5),
|
44
|
+
}
|
45
|
+
|
46
|
+
# A new instance of Note.
|
47
|
+
# @param [Hash] args Hashed arguments. See Note::ARG_SPECS for details.
|
48
|
+
def initialize args={}
|
49
|
+
hash_make args
|
50
|
+
end
|
51
|
+
|
52
|
+
# Compare the equality of another Note object.
|
53
|
+
def == other
|
54
|
+
return (@duration == other.duration) &&
|
55
|
+
(@intervals == other.intervals) &&
|
56
|
+
(@sustain == other.sustain) &&
|
57
|
+
(@attack == other.attack) &&
|
58
|
+
(@separation == other.separation)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Set the note duration.
|
62
|
+
# @param [Numeric] duration The duration to use.
|
63
|
+
# @raise [ArgumentError] if duration is not greater than 0.
|
64
|
+
def duration= duration
|
65
|
+
ARG_SPECS[:duration].validate_value duration
|
66
|
+
@duration = duration
|
67
|
+
end
|
68
|
+
|
69
|
+
# Set the note sustain.
|
70
|
+
# @param [Numeric] sustain The sustain of the note.
|
71
|
+
# @raise [ArgumentError] if sustain is not a Numeric.
|
72
|
+
# @raise [RangeError] if sustain is outside the range 0.0..1.0.
|
73
|
+
def sustain= sustain
|
74
|
+
ARG_SPECS[:sustain].validate_value sustain
|
75
|
+
@sustain = sustain
|
76
|
+
end
|
77
|
+
|
78
|
+
# Set the note attack.
|
79
|
+
# @param [Numeric] attack The attack of the note.
|
80
|
+
# @raise [ArgumentError] if attack is not a Numeric.
|
81
|
+
# @raise [RangeError] if attack is outside the range 0.0..1.0.
|
82
|
+
def attack= attack
|
83
|
+
ARG_SPECS[:attack].validate_value attack
|
84
|
+
@attack = attack
|
85
|
+
end
|
86
|
+
|
87
|
+
# Set the note separation.
|
88
|
+
# @param [Numeric] separation The separation of the note.
|
89
|
+
# @raise [ArgumentError] if separation is not a Numeric.
|
90
|
+
# @raise [RangeError] if separation is outside the range 0.0..1.0.
|
91
|
+
def separation= separation
|
92
|
+
ARG_SPECS[:separation].validate_value separation
|
93
|
+
@separation = separation
|
94
|
+
end
|
95
|
+
|
96
|
+
# Produce an identical Note object.
|
97
|
+
def clone
|
98
|
+
Marshal.load(Marshal.dump(self))
|
99
|
+
end
|
100
|
+
|
101
|
+
# Remove any duplicate intervals (occuring on the same pitch), removing
|
102
|
+
# all but the last occurance. Remove any duplicate links (links to the
|
103
|
+
# same interval), removing all but the last occurance.
|
104
|
+
def remove_duplicates
|
105
|
+
# in case of duplicate notes
|
106
|
+
intervals_to_remove = Set.new
|
107
|
+
for i in (0...@intervals.count).entries.reverse
|
108
|
+
@intervals.each_index do |j|
|
109
|
+
if j < i
|
110
|
+
if @intervals[i].pitch == @intervals[j].pitch
|
111
|
+
intervals_to_remove.add @intervals[j]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
@intervals.delete_if { |interval| intervals_to_remove.include? interval}
|
117
|
+
|
118
|
+
# in case of duplicate links
|
119
|
+
for i in (0...@intervals.count).entries.reverse
|
120
|
+
@intervals.each_index do |j|
|
121
|
+
if j < i
|
122
|
+
if @intervals[i].linked? && @intervals[j].linked? && @intervals[i].link.target_pitch == @intervals[j].link.target_pitch
|
123
|
+
@intervals[j].link = Link.new
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def transpose pitch_diff
|
131
|
+
self.clone.transpose! pitch_diff
|
132
|
+
end
|
133
|
+
|
134
|
+
def transpose! pitch_diff
|
135
|
+
@intervals.each do |interval|
|
136
|
+
interval.pitch += pitch_diff
|
137
|
+
interval.link.target_pitch += pitch_diff
|
138
|
+
end
|
139
|
+
return self
|
140
|
+
end
|
141
|
+
|
142
|
+
def to_s
|
143
|
+
"#{duration}:#{intervals.map{|i| i.pitch}.inspect}"
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
module_function
|
148
|
+
|
149
|
+
def note duration, intervals = [], other_args = {}
|
150
|
+
Note.new(
|
151
|
+
{ :duration => duration, :intervals => intervals }.merge other_args
|
152
|
+
)
|
153
|
+
end
|
154
|
+
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Music
|
4
|
+
module Transcription
|
5
|
+
|
6
|
+
# Abstraction of a musical part. Contains start offset, notes, and loudness_profile settings.
|
7
|
+
#
|
8
|
+
# @author James Tunnell
|
9
|
+
#
|
10
|
+
# @!attribute [rw] offset
|
11
|
+
# @return [Numeric] The offset where the part begins.
|
12
|
+
#
|
13
|
+
# @!attribute [rw] notes
|
14
|
+
# @return [Array] The notes to be played.
|
15
|
+
#
|
16
|
+
# @!attribute [rw] loudness_profile
|
17
|
+
# @return [Profile] The parts loudness_profile profile.
|
18
|
+
#
|
19
|
+
class Part
|
20
|
+
include Hashmake::HashMakeable
|
21
|
+
attr_reader :offset, :loudness_profile, :notes
|
22
|
+
|
23
|
+
# hashed-arg specs (for hash-makeable idiom)
|
24
|
+
ARG_SPECS = {
|
25
|
+
:offset => arg_spec(:reqd => false, :type => Numeric, :default => 0),
|
26
|
+
:loudness_profile => arg_spec(:reqd => false, :type => Profile, :validator => ->(a){ a.values_between?(0.0,1.0) }, :default => ->(){ Profile.new(:start_value => 0.5) }),
|
27
|
+
:notes => arg_spec_array(:reqd => false, :type => Note),
|
28
|
+
}
|
29
|
+
|
30
|
+
# A new instance of Part.
|
31
|
+
# @param [Hash] args Hashed arguments. Valid optional keys are :loudness_profile,
|
32
|
+
# :notes, and :offset.
|
33
|
+
def initialize args = {}
|
34
|
+
hash_make args, Part::ARG_SPECS
|
35
|
+
end
|
36
|
+
|
37
|
+
# Produce an exact copy of the current object
|
38
|
+
def clone
|
39
|
+
Marshal.load(Marshal.dump(self))
|
40
|
+
end
|
41
|
+
|
42
|
+
# Compare the equality of another Part object.
|
43
|
+
def ==(other)
|
44
|
+
return (@offset == other.offset) &&
|
45
|
+
(@loudness_profile == other.loudness_profile) &&
|
46
|
+
(@notes == other.notes)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Set the start offset of the part.
|
50
|
+
# @param [Numeric] offset The start offset of the part.
|
51
|
+
# @raise [ArgumentError] unless offset is a Numeric.
|
52
|
+
def offset= offset
|
53
|
+
ARG_SPECS[:offset].validate_value offset
|
54
|
+
@offset = offset
|
55
|
+
end
|
56
|
+
|
57
|
+
# Set the loudness_profile Profile.
|
58
|
+
# @param [Tempo] loudness_profile The Profile for part loudness_profile.
|
59
|
+
# @raise [ArgumentError] if loudness_profile is not a Profile.
|
60
|
+
def loudness_profile= loudness_profile
|
61
|
+
ARG_SPECS[:loudness_profile].validate_value loudness_profile
|
62
|
+
@loudness_profile = loudness_profile
|
63
|
+
end
|
64
|
+
|
65
|
+
# Duration of part notes.
|
66
|
+
def duration
|
67
|
+
total_duration = @notes.inject(0) { |sum, note| sum + note.duration }
|
68
|
+
return @offset + total_duration
|
69
|
+
end
|
70
|
+
|
71
|
+
# offset where part begins
|
72
|
+
def start
|
73
|
+
return @offset
|
74
|
+
end
|
75
|
+
|
76
|
+
# offset where part ends
|
77
|
+
def end
|
78
|
+
return @offset + duration
|
79
|
+
end
|
80
|
+
|
81
|
+
def transpose pitch_diff
|
82
|
+
self.clone.transpose! pitch_diff
|
83
|
+
end
|
84
|
+
|
85
|
+
def transpose! pitch_diff
|
86
|
+
@notes.each do |note|
|
87
|
+
note.transpose! pitch_diff
|
88
|
+
end
|
89
|
+
return self
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class PartFile < Part
|
94
|
+
include Hashmake::HashMakeable
|
95
|
+
attr_reader :file_path
|
96
|
+
|
97
|
+
# hashed-arg specs (for hash-makeable idiom)
|
98
|
+
ARG_SPECS = {
|
99
|
+
:file_path => arg_spec(:reqd => true, :type => String, :validator => ->(a){ File.exist? a })
|
100
|
+
}
|
101
|
+
|
102
|
+
# A new instance of Part.
|
103
|
+
# @param [Hash] args Hashed arguments. Only valid keys is :file_path.
|
104
|
+
def initialize args
|
105
|
+
hash_make args, PartFile::ARG_SPECS
|
106
|
+
|
107
|
+
unless @file_path.nil?
|
108
|
+
obj = YAML.load_file @file_path
|
109
|
+
|
110
|
+
if obj.is_a?(Part)
|
111
|
+
super(:offset => obj.offset, :notes => obj.notes, :loudness_profile => obj.loudness_profile)
|
112
|
+
elsif obj.is_a?(Hash)
|
113
|
+
super(obj)
|
114
|
+
else
|
115
|
+
raise ArgumentError, "Expected a Hash or Part object"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Produce an exact copy of the current object
|
121
|
+
def clone
|
122
|
+
Marshal.load(Marshal.dump(self))
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,297 @@
|
|
1
|
+
module Music
|
2
|
+
module Transcription
|
3
|
+
|
4
|
+
# Abstraction of a musical pitch. Contains values for octave, semitone,
|
5
|
+
# and cent. These values are useful because they allow simple mapping to
|
6
|
+
# both the abstract (musical scales) and concrete (audio data).
|
7
|
+
#
|
8
|
+
# Fundamentally, pitch can be considered a ratio to some base number.
|
9
|
+
# For music, this is a base frequency. The pitch frequency can be
|
10
|
+
# determined by multiplying the base frequency by the pitch ratio. For
|
11
|
+
# the standard musical scale, the base frequency of C0 is 16.35 Hz.
|
12
|
+
#
|
13
|
+
# Octaves represent the largest means of differing two pitches. Each
|
14
|
+
# octave added will double the ratio. At zero octaves, the ratio is
|
15
|
+
# 1.0. At one octave, the ratio will be 2.0. Each semitone and cent
|
16
|
+
# is an increment of less-than-power-of-two.
|
17
|
+
#
|
18
|
+
# Semitones are the primary steps between octaves. By default, the
|
19
|
+
# number of semitones per octave is 12, corresponding to the twelve-tone equal
|
20
|
+
# temperment tuning system. The number of semitones per octave can be
|
21
|
+
# modified at runtime by overriding the Pitch::SEMITONES_PER_OCTAVE
|
22
|
+
# constant.
|
23
|
+
#
|
24
|
+
# Cents are the smallest means of differing two pitches. By default, the
|
25
|
+
# number of cents per semitone is 100 (hence the name cent, as in per-
|
26
|
+
# cent). This number can be modified at runtime by overriding the
|
27
|
+
# Pitch::CENTS_PER_SEMITONE constant.
|
28
|
+
#
|
29
|
+
# @author James Tunnell
|
30
|
+
#
|
31
|
+
# @!attribute [r] octave
|
32
|
+
# @return [Fixnum] The pitch octave.
|
33
|
+
# @!attribute [r] semitone
|
34
|
+
# @return [Fixnum] The pitch semitone.
|
35
|
+
# @!attribute [r] cent
|
36
|
+
# @return [Fixnum] The pitch cent.
|
37
|
+
# @!attribute [r] cents_per_octave
|
38
|
+
# @return [Fixnum] The number of cents per octave. Default is 1200
|
39
|
+
# (12 x 100). If a different scale is required,
|
40
|
+
# modify CENTS_PER_SEMITONE (default 12) and/or
|
41
|
+
# SEMITONES_PER_OCTAVE (default 100).
|
42
|
+
# @!attribute [r] base_freq
|
43
|
+
# @return [Numeric] Multiplied with pitch ratio to determine the final frequency
|
44
|
+
# of the pitch. Defaults to DEFAULT_BASE_FREQ, but can be set
|
45
|
+
# during initialization to something else using the :base_freq key.
|
46
|
+
#
|
47
|
+
class Pitch
|
48
|
+
include Comparable
|
49
|
+
include Hashmake::HashMakeable
|
50
|
+
attr_reader :cents_per_octave, :base_freq, :octave, :semitone, :cent
|
51
|
+
|
52
|
+
#The default number of semitones per octave is 12, corresponding to
|
53
|
+
# the twelve-tone equal temperment tuning system.
|
54
|
+
SEMITONES_PER_OCTAVE = 12
|
55
|
+
|
56
|
+
#The default number of cents per semitone is 100 (hence the name cent,
|
57
|
+
# as in percent).
|
58
|
+
CENTS_PER_SEMITONE = 100
|
59
|
+
|
60
|
+
# The default base ferquency is C0
|
61
|
+
DEFAULT_BASE_FREQ = 16.351597831287414
|
62
|
+
|
63
|
+
# hashed-arg specs (for hash-makeable idiom)
|
64
|
+
ARG_SPECS = {
|
65
|
+
:octave => arg_spec(:reqd => false, :type => Fixnum, :default => 0),
|
66
|
+
:semitone => arg_spec(:reqd => false, :type => Fixnum, :default => 0),
|
67
|
+
:cent => arg_spec(:reqd => false, :type => Fixnum, :default => 0),
|
68
|
+
:base_freq => arg_spec(:reqd => false, :type => Numeric, :validator => ->(a){ a > 0.0 }, :default => DEFAULT_BASE_FREQ)
|
69
|
+
}
|
70
|
+
|
71
|
+
# A new instance of Pitch.
|
72
|
+
# @param [Hash] args Hashed args. See ARG_SPECS for details.
|
73
|
+
# @raise [ArgumentError] if any of :octave, :semitone, or :cent is
|
74
|
+
# not a Fixnum.
|
75
|
+
def initialize args={}
|
76
|
+
@cents_per_octave = CENTS_PER_SEMITONE * SEMITONES_PER_OCTAVE
|
77
|
+
hash_make args
|
78
|
+
normalize!
|
79
|
+
end
|
80
|
+
|
81
|
+
# Set @base_freq, which is used with the pitch ratio to produce the
|
82
|
+
# pitch frequency.
|
83
|
+
def base_freq= base_freq
|
84
|
+
ARG_SPECS[:base_freq].validate_value base_freq
|
85
|
+
@base_freq = base_freq
|
86
|
+
end
|
87
|
+
|
88
|
+
# Set @octave.
|
89
|
+
def octave= octave
|
90
|
+
ARG_SPECS[:octave].validate_value octave
|
91
|
+
@octave = octave
|
92
|
+
end
|
93
|
+
|
94
|
+
# Set semitone.
|
95
|
+
def semitone= semitone
|
96
|
+
ARG_SPECS[:semitone].validate_value semitone
|
97
|
+
@semitone = semitone
|
98
|
+
end
|
99
|
+
|
100
|
+
# Set @cent.
|
101
|
+
def cent= cent
|
102
|
+
ARG_SPECS[:cent].validate_value cent
|
103
|
+
@cent = cent
|
104
|
+
end
|
105
|
+
|
106
|
+
# Return the pitch's frequency, which is determined by multiplying the base
|
107
|
+
# frequency and the pitch ratio. Base frequency defaults to DEFAULT_BASE_FREQ,
|
108
|
+
# but can be set during initialization to something else by specifying the
|
109
|
+
# :base_freq key.
|
110
|
+
def freq
|
111
|
+
return self.ratio() * @base_freq
|
112
|
+
end
|
113
|
+
|
114
|
+
# Set the pitch according to the given frequency. Uses the current base_freq
|
115
|
+
# to determine what the pitch ratio should be, and sets it accordingly.
|
116
|
+
def freq= freq
|
117
|
+
self.ratio = freq / @base_freq
|
118
|
+
end
|
119
|
+
|
120
|
+
# Calculate the total cent count. Converts octave and semitone count
|
121
|
+
# to cent count before adding to existing cent count.
|
122
|
+
# @return [Fixnum] total cent count
|
123
|
+
def total_cent
|
124
|
+
return (@octave * @cents_per_octave) +
|
125
|
+
(@semitone * CENTS_PER_SEMITONE) + @cent
|
126
|
+
end
|
127
|
+
|
128
|
+
# Set the Pitch ratio according to a total number of cents.
|
129
|
+
# @param [Fixnum] cent The total number of cents to use.
|
130
|
+
# @raise [ArgumentError] if cent is not a Fixnum
|
131
|
+
def total_cent= cent
|
132
|
+
raise ArgumentError, "cent is not a Fixnum" if !cent.is_a?(Fixnum)
|
133
|
+
@octave, @semitone, @cent = 0, 0, cent
|
134
|
+
normalize!
|
135
|
+
end
|
136
|
+
|
137
|
+
# Calculate the pitch ratio. Raises 2 to the power of the total cent
|
138
|
+
# count divided by cents-per-octave.
|
139
|
+
# @return [Float] ratio
|
140
|
+
def ratio
|
141
|
+
2.0**(self.total_cent.to_f / @cents_per_octave)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Represent the Pitch ratio according to a ratio.
|
145
|
+
# @param [Numeric] ratio The ratio to represent.
|
146
|
+
# @raise [RangeError] if ratio is less than or equal to 0.0
|
147
|
+
def ratio= ratio
|
148
|
+
raise RangeError, "ratio #{ratio} is less than or equal to 0.0" if ratio <= 0.0
|
149
|
+
|
150
|
+
x = Math.log2 ratio
|
151
|
+
self.total_cent = (x * @cents_per_octave).round
|
152
|
+
end
|
153
|
+
|
154
|
+
# Round to the nearest semitone.
|
155
|
+
def round
|
156
|
+
self.clone.round!
|
157
|
+
end
|
158
|
+
|
159
|
+
# Round to the nearest semitone.
|
160
|
+
def round!
|
161
|
+
if @cent >= (CENTS_PER_SEMITONE / 2)
|
162
|
+
@semitone += 1
|
163
|
+
end
|
164
|
+
@cent = 0
|
165
|
+
normalize!
|
166
|
+
return self
|
167
|
+
end
|
168
|
+
|
169
|
+
# Calculates the number of semitones which would represent the pitch's
|
170
|
+
# octave and semitone count. Excludes cents.
|
171
|
+
def total_semitone
|
172
|
+
return (@octave * SEMITONES_PER_OCTAVE) + @semitone
|
173
|
+
end
|
174
|
+
|
175
|
+
# Override default hash method.
|
176
|
+
def hash
|
177
|
+
return self.total_cent
|
178
|
+
end
|
179
|
+
|
180
|
+
# Compare pitch equality using total cent
|
181
|
+
def ==(other)
|
182
|
+
self.total_cent == other.total_cent
|
183
|
+
end
|
184
|
+
|
185
|
+
# Compare pitches. A higher ratio or total cent is considered larger.
|
186
|
+
# @param [Pitch] other The pitch object to compare.
|
187
|
+
def <=> (other)
|
188
|
+
self.total_cent <=> other.total_cent
|
189
|
+
end
|
190
|
+
|
191
|
+
# Add pitches by adding the total cent count of each.
|
192
|
+
# @param [Pitch] other The pitch object to add.
|
193
|
+
def + (other)
|
194
|
+
self.class.new :octave => (@octave + other.octave), :semitone => (@semitone + other.semitone), :cent => (@cent + other.cent)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Add pitches by subtracting the total cent count.
|
198
|
+
# @param [Pitch] other The pitch object to subtract.
|
199
|
+
def - (other)
|
200
|
+
self.class.new :octave => (@octave - other.octave), :semitone => (@semitone - other.semitone), :cent => (@cent - other.cent)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Produce an identical Pitch object.
|
204
|
+
def clone
|
205
|
+
Marshal.load(Marshal.dump(self))
|
206
|
+
end
|
207
|
+
|
208
|
+
# Balance out the octave, semitone, and cent count.
|
209
|
+
def normalize!
|
210
|
+
centTotal = (@octave * @cents_per_octave) + (@semitone * CENTS_PER_SEMITONE) + @cent
|
211
|
+
|
212
|
+
@octave = centTotal / @cents_per_octave
|
213
|
+
centTotal -= @octave * @cents_per_octave
|
214
|
+
|
215
|
+
@semitone = centTotal / CENTS_PER_SEMITONE
|
216
|
+
centTotal -= @semitone * CENTS_PER_SEMITONE
|
217
|
+
|
218
|
+
@cent = centTotal
|
219
|
+
return self
|
220
|
+
end
|
221
|
+
|
222
|
+
# Produce a string representation of a pitch (e.g. "C2")
|
223
|
+
def to_s
|
224
|
+
if @cents_per_octave != 1200
|
225
|
+
raise "Don't know how to produce a string representation since cents_per_octave is not 1200."
|
226
|
+
end
|
227
|
+
|
228
|
+
semitone_str = case @semitone
|
229
|
+
when 0 then "C"
|
230
|
+
when 1 then "Db"
|
231
|
+
when 2 then "D"
|
232
|
+
when 3 then "Eb"
|
233
|
+
when 4 then "E"
|
234
|
+
when 5 then "F"
|
235
|
+
when 6 then "Gb"
|
236
|
+
when 7 then "G"
|
237
|
+
when 8 then "Ab"
|
238
|
+
when 9 then "A"
|
239
|
+
when 10 then "Bb"
|
240
|
+
when 11 then "B"
|
241
|
+
end
|
242
|
+
|
243
|
+
return semitone_str + @octave.to_s
|
244
|
+
end
|
245
|
+
|
246
|
+
def self.make_from_freq(freq, base_freq = DEFAULT_BASE_FREQ)
|
247
|
+
pitch = Pitch.new()
|
248
|
+
pitch.ratio = freq / base_freq
|
249
|
+
return pitch
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
class String
|
257
|
+
# Create a Pitch object from a string (e.g. "C2"). String can contain a letter (A-G),
|
258
|
+
# to indicate the semitone, followed by an optional sharp/flat (#/b) and then the
|
259
|
+
# octave number (non-negative integer).
|
260
|
+
def to_pitch
|
261
|
+
string = self
|
262
|
+
if string =~ /[AaBbCcDdEeFfGg][#b][\d]+/
|
263
|
+
semitone = letter_to_semitone string[0]
|
264
|
+
semitone = case string[1]
|
265
|
+
when "#" then semitone + 1
|
266
|
+
when "b" then semitone - 1
|
267
|
+
else raise ArgumentError, "unexpected symbol found"
|
268
|
+
end
|
269
|
+
octave = string[2..-1].to_i
|
270
|
+
return Music::Transcription::Pitch.new(:octave => octave, :semitone => semitone)
|
271
|
+
elsif string =~ /[AaBbCcDdEeFfGg][\d]+/
|
272
|
+
semitone = letter_to_semitone string[0]
|
273
|
+
octave = string[1..-1].to_i
|
274
|
+
return Music::Transcription::Pitch.new(:octave => octave, :semitone => semitone)
|
275
|
+
else
|
276
|
+
raise ArgumentError, "string #{string} cannot be converted to a pitch"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
private
|
281
|
+
|
282
|
+
def letter_to_semitone letter
|
283
|
+
semitone = case letter
|
284
|
+
when /[Cc]/ then 0
|
285
|
+
when /[Dd]/ then 2
|
286
|
+
when /[Ee]/ then 4
|
287
|
+
when /[Ff]/ then 5
|
288
|
+
when /[Gg]/ then 7
|
289
|
+
when /[Aa]/ then 9
|
290
|
+
when /[Bb]/ then 11
|
291
|
+
else raise ArgumentError, "invalid letter \"#{letter}\" given"
|
292
|
+
end
|
293
|
+
|
294
|
+
return semitone
|
295
|
+
end
|
296
|
+
|
297
|
+
end
|