step_sequencer 1.0.0

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: 7f05e5833d38340144b70f118284273333692da7
4
+ data.tar.gz: f1f28455b5272f7dff4a7d2d3aa578bcf64fec49
5
+ SHA512:
6
+ metadata.gz: 4b600c61efe15377b72893b484b020894e7aba4cd4e902da2f0d46d99146bf98aea810624b23d0d603cf4020724d95188ca8cd21eb1d3cfe3663cecbb44f7281
7
+ data.tar.gz: 170de1a63b8618181f7536fcfdcb8687602eaea3dd9e62ac07a3b68995188c32307f6edb2c432cf54542e5c734dcfa61be1049cbc124c29ea5d310f8ba294537
data/README.md ADDED
@@ -0,0 +1,233 @@
1
+ ## Step Sequencer
2
+
3
+ #### About
4
+
5
+ This is a Ruby tool to play mp3 files in a step sequencer.
6
+
7
+ It also handles polyrhythmic playback and
8
+
9
+ #### Depedencies
10
+
11
+ Some external programs need to be installed (via `apt-get`, `brew`, `yum`, etc)
12
+
13
+ `mpg123 ffmpeg sox libsox-fmt-mp3`
14
+
15
+ To run the tests, the program `espeak` is also required.
16
+
17
+ #### Installing
18
+
19
+ ```sh
20
+ gem install step_sequencer
21
+ ```
22
+
23
+ ```rb
24
+ require 'step_sequencer'
25
+ ```
26
+
27
+ #### Usage
28
+
29
+ There are two main components: `StepSequencer::SoundBuilder` and
30
+ `StepSequencer::SoundPlayer`. A third component,
31
+ `StepSequencer::SoundAnalyser`, can also be useful.
32
+
33
+ ##### 1. **`StepSequencer::SoundBuilder`**
34
+
35
+ There are number of `StepSequencer::SoundBuilder::EffectsComponents`
36
+ (this constant refers to a hash, the values of which are classes).
37
+ All of them inherit from and implement the protocol of
38
+ `StepSequencer::SoundBuilder::EffectsComponentProtocol`. To use a custom effect,
39
+ add the class to this list. Through the protocol, they're connected to the
40
+ `StepSequencer::SoundBuilder.build` method.
41
+
42
+ ```rb
43
+ # returns nested array (array of generated sounds for each source)
44
+ builder = StepSequencer::SoundBuilder
45
+ filenames = builder.build(
46
+ sources: ["middle_c.mp3"],
47
+ effect: :Scale,
48
+ args: [{scale: :equal_temperament}]
49
+ )
50
+ ```
51
+
52
+ The `:Scale` effect also allows an `inverse: true` key in `args` which will
53
+ set all pitch change values to their inverse (creating a descending scale).
54
+
55
+ Now say I want to apply a 150% gain to all these files:
56
+
57
+ ```rb
58
+ # returns array of paths, one for each source
59
+ new_filenames = builder.build(
60
+ sources: filenames.shift,
61
+ effect: :Gain,
62
+ args: [{value: 1.5}]
63
+ )
64
+ ```
65
+
66
+ Other builtin effects:
67
+
68
+ _change speed_ (returns array of paths, one for each source)
69
+
70
+ ```rb
71
+ new_filenames = builder.build(
72
+ sources: new_filenames,
73
+ effect: :Speed,
74
+ args: [{value: 0.5}]
75
+ )
76
+ ```
77
+
78
+ _change pitch_ (returns array of paths, one for each source)
79
+
80
+ ```rb
81
+ new_filenames = builder.build(
82
+ sources: filenames,
83
+ effect: :Pitch,
84
+ args: [{value: 2}] # doubling pitch to account for 0.5 speed
85
+ )
86
+ # By default this will adjust the speed so that only the pitch changes.
87
+ # However the `speed_correction: false` arg prevents this.
88
+ ```
89
+
90
+ _loop_ (returns array of paths, one for each source)
91
+
92
+ ```rb
93
+ new_filenames = builder.build(
94
+ sources: filenames,
95
+ effect: :Loop,
96
+ args: [{times: 1.5}]
97
+ )
98
+ ```
99
+
100
+ _slice_ (returns array of paths, one for each source)
101
+
102
+ ```rb
103
+ new_filenames = builder.build(
104
+ sources: filenames,
105
+ effect: :Slice,
106
+ args: [{start_time: 0.5, end_time: 1}] # A 0.5 second slice
107
+ # start_pct and end_pct can be used for percentage-based slicing, e.g.
108
+ # {start_pct: 25, end_pct: 75} which will take a 50% slice from the middle.
109
+ )
110
+ ```
111
+
112
+ _combine_ (returns single path)
113
+
114
+ ```rb
115
+ new_filenames = builder.build(
116
+ sources: filenames,
117
+ effect: :Combine,
118
+ args: [{filename: "foo.mp3"}], # filename arg is optional,
119
+ # and one will be auto-generated otherwise.
120
+ )
121
+ `
122
+
123
+ _overlay_ (returns single path)
124
+
125
+ ```rb
126
+ new_filenames = builder.build(
127
+ sources: filenames,
128
+ effect: :Overlay,
129
+ args: [{filename: "foo.mp3"}], # filename arg is optional,
130
+ # and one will be auto-generated otherwise.
131
+ )
132
+ `
133
+
134
+ As the above examples illustrate, `#build` is always given an array of sources
135
+ (audio paths). `effect` is always a symbol, and although it can be ommitted if
136
+ it's empty, `args` is always a array, the signature of which is dependent on the
137
+ specific effects component's implementation.
138
+
139
+ **2. `StepSequencer::SoundPlayer`**
140
+
141
+ Playing sounds is a two part process. First, the player must be initialized,
142
+ which is when the sounds are mapped to rows.
143
+
144
+ For example, say I want to plug in the sounds I created earlier using
145
+ `StepSequencer::SoundBuilder#build`. I have 12 sounds and I want to give each
146
+ of them their own row in the sequencer. This pretty easy to do:
147
+
148
+ ```rb
149
+ player = StepSequencer::SoundPlayer.new(filenames)
150
+ ```
151
+
152
+ After `#initialize`, only one other method needs to get called, and that's `play`.
153
+ As you might have expected by now, it's overloaded as well:
154
+
155
+ ```rb
156
+ # This will play the descending scale, there is 1 row for each note
157
+ player.play(
158
+ tempo: 240
159
+ string: <<-TXT
160
+ x _ _ _ _ _ _ _ _ _ _ _
161
+ _ x _ _ _ _ _ _ _ _ _ _
162
+ _ _ x _ _ _ _ _ _ _ _ _
163
+ _ _ _ x _ _ _ _ _ _ _ _ # comments can be added too
164
+ _ _ _ _ x _ _ _ _ _ _ _
165
+ _ _ _ _ _ x _ _ _ _ _ _
166
+ _ _ _ _ _ _ x _ _ _ _ _
167
+ _ _ _ _ _ _ _ x _ _ _ _
168
+ _ _ _ _ _ _ _ _ x _ _ _
169
+ _ _ _ _ _ _ _ _ _ x _ _
170
+ _ _ _ _ _ _ _ _ _ _ x _
171
+ _ _ _ _ _ _ _ _ _ _ _ x
172
+ TXT
173
+ )
174
+ ```
175
+
176
+ To use something other than `x` or `_`, use the environment variables
177
+ `STEP_SEQUENCER_GRID_HIT_CHAR` and `STEP_SEQUENCER_GRID_REST_CHAR`
178
+
179
+ ```rb
180
+ # plays the same notes, but with nested arrays.
181
+ # the note/rest are denoted by 1 and nil, respectively
182
+ player.play(
183
+ tempo: 240,
184
+ matrix: 0.upto(11).reduce([]) do |rows, i|
185
+ rows.push Array.new(12, nil)
186
+ rows[-1][i] = 1
187
+ rows
188
+ end
189
+ )
190
+ ```
191
+
192
+ Some other notes:
193
+
194
+ - this intentionally doesn't validate that the rows
195
+ are the same length. This is so that polyrhythms can be played.
196
+ - by default the rows are looped forever in a background thread. To stop, simply
197
+ `player.stop`. To trigger playback for a limited time only, pass a `limit` option
198
+ which is an integer representing a number of _total hits_. I.e. if I wanted to
199
+ play the aformentioned 12-step grid 4 times, I'd pass a limit of 48.
200
+ - although there is no option to make `play` happen synchronously, just use
201
+ something like `sleep 0.5 while player.playing`
202
+ - the tempo can be understood as BPM in quarter notes. So to get the same speed
203
+ as 16th notes at 120 BPM, use a tempo of 480. The default is 120.
204
+ - The player isn't configured to be manipulated while playing. Use a new instance instead.
205
+
206
+ #### Todos
207
+
208
+ - precompile the grid into a single mp3. this will result in optimal playback
209
+ but may be slightly difficult considering many sounds can be played at once,
210
+ and a single sound can be played over itself.
211
+
212
+ #### Tests
213
+
214
+ The tests are found in lib/step_sequencer/tests.rb. They are not traditional
215
+ unit tests since the value cannot be automatically determined to be correct.
216
+ Rather, it generates sounds and then plays them back for manual aural
217
+ validation.
218
+
219
+ A couple small (1 second) mp3s are bundled with the gem and are used in the tests.
220
+ To run the tests from the command line after installing the gem:
221
+
222
+ ```rb
223
+ step_sequencer test
224
+ ```
225
+
226
+ They are packaged with the distributed gem, so after `require 'step_sequencer'`
227
+ just use `StepSequencer::Tests.run`. Individual cases can also be run by calling
228
+ the class methods of `Builder` and `Player` in `StepSequencer::Tests::TestCases`.
229
+
230
+ There is one extra dependency required to use the tests, and that's the
231
+ `espeak-ruby` gem. which will indicate what sounds are playing.
232
+ This requires the external tool to be installed as well,
233
+ e.g. `brew install espeak` or `sudo apt-get install espeak`.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ require 'step_sequencer'
3
+ require 'thor'
4
+ class StepSequencer::CLI < Thor
5
+ desc "test", "run tests"
6
+ def test
7
+ StepSequencer::Tests.run
8
+ end
9
+ end
10
+ StepSequencer::CLI.start ARGV
@@ -0,0 +1,14 @@
1
+ sounds = [
2
+ "blip_1.mp3",
3
+ "blip_1.mp3",
4
+ "blip_1.mp3",
5
+ ]
6
+
7
+ 4.times do
8
+ sounds.each do |sound|
9
+ Thread.new do
10
+ `mpg123 #{sound} 2> /dev/null`
11
+ end
12
+ end
13
+ sleep 0.5
14
+ end
@@ -0,0 +1,124 @@
1
+ module StepSequencer::Refinements
2
+
3
+ # =============================================================================
4
+ # String#strip_heredoc
5
+ # -----------------------------------------------------------------------------
6
+ # Another one from active support, this lets heredocs be indented without
7
+ # effecting the underlying string
8
+ # =============================================================================
9
+ module StringStripHeredoc
10
+ def strip_heredoc
11
+ indent = scan(/^[ \t]*(?=\S)/).min.__send__(:size) || 0
12
+ gsub(/^[ \t]{#{indent}}/, '')
13
+ end
14
+ refine String do
15
+ include StepSequencer::Refinements::StringStripHeredoc
16
+ end
17
+ end
18
+
19
+
20
+ # =============================================================================
21
+ # String#blank
22
+ # -----------------------------------------------------------------------------
23
+ # ActiveSupport provides a similar method for many classes,
24
+ # but from my experience the most useful is the String patch
25
+ # =============================================================================
26
+ module StringBlank
27
+ def blank?
28
+ /\A[[:space:]]*\z/ === self
29
+ end
30
+ refine String do
31
+ include StepSequencer::Refinements::StringBlank
32
+ end
33
+ end
34
+
35
+ # Object#yield_self
36
+ # -----------------------------------------------------------------------------
37
+ # is in Ruby since a v2.5 patch although there it's implemented on Kernel,
38
+ # which, being a module, makes it difficult to refine.
39
+ # Anyway, this is a backport; and it's a incredibly simply function
40
+ # =============================================================================
41
+ module ObjectYieldSelf
42
+ def yield_self(&blk)
43
+ blk&.call self
44
+ end
45
+ refine Object do
46
+ include StepSequencer::Refinements::ObjectYieldSelf
47
+ end
48
+ end
49
+
50
+ # =============================================================================
51
+ # String#rational_eval
52
+ # -----------------------------------------------------------------------------
53
+ # a method I've written to help work with rational numbers.
54
+ # It evals a string containing math, but wraps all number values in a call
55
+ # It raises an error if the result is not a rational
56
+ # =============================================================================
57
+ module StringRationalEval
58
+ def rational_eval
59
+ result = eval <<-RB
60
+ Rational(#{
61
+ gsub(/\d[\d\.\_]*/) { |str| "Rational(#{str})"}
62
+ })
63
+ RB
64
+ result.is_a?(Rational) ? result : raise("#{result} is not Rational")
65
+ end
66
+ refine String do
67
+ include StepSequencer::Refinements::StringRationalEval
68
+ end
69
+ end
70
+
71
+ # =============================================================================
72
+ # Symbol#call
73
+ # -----------------------------------------------------------------------------
74
+ # cryptic but immensely useful.
75
+ # It enables passing arguments with the symbol-to-proc shorthand, e.g.:
76
+ # [1,2,3].map(&:+.(1)) == [2,3,4]
77
+ # source: https://stackoverflow.com/a/23711606/2981429
78
+ # =============================================================================
79
+ module SymbolCall
80
+ def call(*args, &block)
81
+ ->(caller, *rest) { caller.send(self, *rest, *args, &block) }
82
+ end
83
+ refine Symbol do
84
+ include StepSequencer::Refinements::SymbolCall
85
+ end
86
+ end
87
+
88
+ # =============================================================================
89
+ # String#constantize
90
+ # -----------------------------------------------------------------------------
91
+ # comes from activesupport
92
+ # =============================================================================
93
+ module StringConstantize
94
+ def constantize
95
+ names = self.split("::".freeze)
96
+ # Trigger a built-in NameError exception including the ill-formed constant in the message.
97
+ Object.const_get(self) if names.empty?
98
+ # Remove the first blank element in case of '::ClassName' notation.
99
+ names.shift if names.size > 1 && names.first.empty?
100
+ names.inject(Object) do |constant, name|
101
+ if constant == Object
102
+ constant.const_get(name)
103
+ else
104
+ candidate = constant.const_get(name)
105
+ next candidate if constant.const_defined?(name, false)
106
+ next candidate unless Object.const_defined?(name)
107
+ # Go down the ancestors to check if it is owned directly. The check
108
+ # stops when we reach Object or the end of ancestors tree.
109
+ constant = constant.ancestors.inject(constant) do |const, ancestor|
110
+ break const if ancestor == Object
111
+ break ancestor if ancestor.const_defined?(name, false)
112
+ const
113
+ end
114
+ # owner is in Object, so raise
115
+ constant.const_get(name, false)
116
+ end
117
+ end
118
+ end
119
+ refine String do
120
+ include StepSequencer::Refinements::StringConstantize
121
+ end
122
+
123
+ end
124
+ end
@@ -0,0 +1,25 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Combine < protocol
4
+
5
+ # This combines the files one after another.
6
+ # To make them overlap, use Overlay
7
+
8
+ def self.build(sources:, filename: nil)
9
+ concat_cmd = "concat:#{sources.join("|")}"
10
+ outfile = filename || generate_outfile_path(sources)
11
+ system %{ffmpeg -y -i "#{concat_cmd}" -c copy #{outfile} 2> /dev/null }
12
+ outfile
13
+ end
14
+
15
+ # The following is an alternate script to combine:
16
+ # `sox #{sources.join(" ")} #{outfile}`
17
+
18
+ class << self
19
+ private
20
+ def generate_outfile_path(sources)
21
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
22
+ end
23
+ end
24
+
25
+ end
@@ -0,0 +1,21 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Gain < protocol
4
+
5
+ def self.build(sources:, value:)
6
+ sources.map do |path|
7
+ outfile = build_outfile_path path, value
8
+ `ffmpeg -y -i "#{path}" -af "volume=#{value.to_f}" #{outfile} 2> /dev/null`
9
+ outfile
10
+ end
11
+ end
12
+
13
+ class << self
14
+ private
15
+ def build_outfile_path path, value
16
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
17
+ end
18
+ end
19
+
20
+
21
+ end
@@ -0,0 +1,47 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Loop < protocol
4
+
5
+ def self.build(sources:, times:)
6
+ num_full_loops = times.to_i # rounds down
7
+ num_partial_loops = times - num_full_loops.to_f
8
+ outfiles = build_full_loops(sources, num_full_loops)
9
+ if num_partial_loops > 0
10
+ outfiles = build_partial_loops(sources, outfiles, num_partial_loops)
11
+ end
12
+ outfiles
13
+ end
14
+
15
+ class << self
16
+ private
17
+ def build_outfile_path
18
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
19
+ end
20
+ def build_partial_loops(sources, outfiles, num_partial_loops)
21
+ outfiles.map.with_index do |outfile, idx|
22
+ source = sources[idx]
23
+ sliced = slice_source(source, num_partial_loops)
24
+ combine([outfile, sliced])
25
+ end
26
+ end
27
+ def build_full_loops(sources, num_full_loops)
28
+ sources.map do |source|
29
+ combine(Array.new(num_full_loops, source))
30
+ end
31
+ end
32
+ def slice_source(source, num_partial_loops)
33
+ builder.build(
34
+ sources: [source],
35
+ effect: :Slice,
36
+ args: [{start_pct: 0.0, end_pct: (num_partial_loops * 100) }]
37
+ )[0]
38
+ end
39
+ def combine(sources)
40
+ builder.build(
41
+ sources: sources,
42
+ effect: :Combine
43
+ )
44
+ end
45
+ end
46
+
47
+ end
@@ -0,0 +1,35 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Overlay < protocol
4
+
5
+ def self.build(sources:, filename: nil)
6
+ # ensure_correct_sample_rates(sources)
7
+ outfile = filename || build_outfile_path
8
+ # `sox --combine mix #{sources.join(" ")} #{outfile}`
9
+ system <<-SH
10
+ ffmpeg -i #{sources.join(" -i ")} \
11
+ -filter_complex amerge -ac 2 -c:a libmp3lame -q:a 4 \
12
+ #{outfile} 2> /dev/null
13
+ SH
14
+ outfile
15
+ end
16
+
17
+ class << self
18
+ private
19
+ def build_outfile_path
20
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
21
+ end
22
+ # def ensure_correct_sample_rates(sources)
23
+ # sources.each do |source|
24
+ # tmp_path = "#{SecureRandom.urlsafe_base64}.mp3"
25
+ # `sox -r 44.1k #{source} #{tmp_path}`
26
+ # unless File.exists?(tmp_path)
27
+ # raise StandardError, "an error occurred setting sample rate in Overlay"
28
+ # end
29
+ # `rm #{source}`
30
+ # `mv #{tmp_path} #{source}`
31
+ # end
32
+ # end
33
+ end
34
+
35
+ end
@@ -0,0 +1,40 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Pitch < protocol
4
+
5
+ def self.build(sources:, value:, speed_correction: true)
6
+ sources.map &change_pitch(value, speed_correction)
7
+ end
8
+
9
+ def self.change_pitch(value, speed_correction)
10
+ ->(source){
11
+ outfile = build_outfile_name(source, value)
12
+ cmd = <<-SH
13
+ ffmpeg -y -i #{source} -af \
14
+ asetrate=44100*#{value} \
15
+ #{outfile} \
16
+ 2> /dev/null
17
+ SH
18
+ system cmd
19
+ return outfile unless speed_correction
20
+ outfile_with_correct_speed = correct_speed(outfile, value)
21
+ outfile_with_correct_speed
22
+ }
23
+ end
24
+
25
+ class << self
26
+ private
27
+ def build_outfile_name(source, value)
28
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
29
+ end
30
+ def correct_speed(outfile, pitch_change_value)
31
+ inverse_value = Rational(1) / Rational(pitch_change_value)
32
+ builder.build(
33
+ sources: [outfile],
34
+ effect: :Speed,
35
+ args: [value: inverse_value]
36
+ ).shift
37
+ end
38
+ end
39
+
40
+ end
@@ -0,0 +1,34 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Scale < protocol
4
+
5
+ using StepSequencer.refinement(:SymbolCall)
6
+ using StepSequencer.refinement(:StringRationalEval)
7
+ using StepSequencer.refinement(:ObjectYieldSelf)
8
+
9
+ # Each returns an array of floats, representing pitch change from original
10
+ Scales = {
11
+ equal_temperament: begin
12
+ twelth_root_of_two = "2 ** (1 / 12)".rational_eval
13
+ 1.upto(12).reduce([twelth_root_of_two]) do |memo|
14
+ memo.concat([memo[-1] * twelth_root_of_two])
15
+ end
16
+ end
17
+ }
18
+
19
+ def self.build(sources:, scale:, inverse: false, speed_correction: true)
20
+ pitch_changes = Scales.fetch(scale).yield_self do |notes|
21
+ inverse ? notes.map(&Rational(1).method(:/)) : notes
22
+ end
23
+ sources.map do |source|
24
+ pitch_changes.flat_map do |pitch_change|
25
+ builder.build(
26
+ sources: [source],
27
+ effect: :Pitch,
28
+ args: [value: pitch_change, speed_correction: speed_correction]
29
+ )
30
+ end
31
+ end
32
+ end
33
+
34
+ end
@@ -0,0 +1,46 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Slice < protocol
4
+
5
+ def self.build(sources:, start_pct: nil, end_pct: nil, start_time: nil, end_time: nil)
6
+ sources.map do |source|
7
+ len = get_audio_length(source)
8
+ start_time ||= calc_start_time(source, len, start_pct)
9
+ end_time ||= calc_end_time(source, len, end_pct)
10
+ diff = (end_time - start_time).round(6)
11
+ outfile = build_outfile_path
12
+ `sox #{source} #{outfile} trim #{start_time} #{diff} 2> /dev/null`
13
+ outfile
14
+ end
15
+ end
16
+
17
+ class << self
18
+ private
19
+ def build_outfile_path
20
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
21
+ end
22
+ def get_audio_length(source)
23
+ raise(
24
+ StandardError,
25
+ "#{source} doesn't exist (can't slice)"
26
+ ) unless File.exists?(source)
27
+ `soxi -D #{source}`.to_f
28
+ end
29
+ def calc_start_time(source, len, start_pct)
30
+ raise(
31
+ StandardError,
32
+ "one of start_pct or start_time needs to be passed to Slice"
33
+ ) unless start_pct
34
+ start_pct_decimal = start_pct.to_f / 100.0
35
+ len * start_pct_decimal
36
+ end
37
+ def calc_end_time(sourse, len, end_pct)
38
+ raise(
39
+ StandardError,
40
+ "one of end_pct or end_time needs to be passed to Slice"
41
+ ) unless end_pct
42
+ end_pct_decimal = end_pct.to_f / 100.0
43
+ len * end_pct_decimal
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ protocol = StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ class StepSequencer::SoundBuilder::DefaultEffects::Speed < protocol
4
+
5
+ def self.build(sources:, value:)
6
+ sources.map do |path|
7
+ outfile = build_outfile_path path, value
8
+ `sox #{path} #{outfile} tempo #{value.to_f} 2> /dev/null`
9
+ outfile
10
+ end
11
+ end
12
+
13
+ class << self
14
+ private
15
+ def build_outfile_path path, value
16
+ "#{output_dir}/#{SecureRandom.urlsafe_base64}.mp3"
17
+ end
18
+ end
19
+
20
+ end
@@ -0,0 +1,2 @@
1
+ class StepSequencer::SoundBuilder::DefaultEffects
2
+ end
@@ -0,0 +1,27 @@
1
+ class StepSequencer::SoundBuilder::EffectsComponentProtocol
2
+
3
+ # receives dispatch from SoundBuilder.build
4
+ def self.build(sources:, args:)
5
+ raise "
6
+ ERROR.
7
+ Something inheriting from #{self.class} didn't implement #build.
8
+ "
9
+ end
10
+
11
+ class << self
12
+
13
+ private
14
+
15
+ # Helper method to call other effects
16
+ def builder
17
+ StepSequencer::SoundBuilder
18
+ end
19
+
20
+ # Any created files should be placed in here (see sound_builder.rb)
21
+ def output_dir
22
+ builder::OutputDir
23
+ end
24
+
25
+ end
26
+
27
+ end
@@ -0,0 +1,27 @@
1
+ class StepSequencer::SoundBuilder
2
+
3
+ # Check the ENV config for an output dir, otherwise use a default one
4
+ OutputDir = ENV.fetch(
5
+ "STEP_SEQUENCER_OUTPUT_DIR",
6
+ "./.step_sequencer/generated"
7
+ ).tap do |path|
8
+ `mkdir -p #{path}`
9
+ raise(
10
+ StandardError,
11
+ "#{path} dir couldn't be created/found. Maybe create it manually."
12
+ ) unless File.directory?(path)
13
+ end
14
+
15
+ def self.build(sources:, effect:, args: [{}])
16
+ effect_class = effects_components[effect]
17
+ effect_class.build({sources: sources}.merge *args)
18
+ end
19
+
20
+ class << self
21
+ private
22
+ def effects_components
23
+ StepSequencer::SoundBuilder::EffectsComponents
24
+ end
25
+ end
26
+
27
+ end
@@ -0,0 +1,132 @@
1
+ class StepSequencer::SoundPlayer
2
+
3
+ using StepSequencer.refinement(:StringBlank)
4
+
5
+ HitChar = ENV.fetch("STEP_SEQUENCER_GRID_HIT_CHAR", "x")
6
+ RestChar = ENV.fetch("STEP_SEQUENCER_GRID_REST_CHAR", "_")
7
+
8
+ if [HitChar, RestChar].any? &:blank?
9
+ raise StandardError, "HitChar or RestChar cannot be just whitespace"
10
+ end
11
+
12
+ attr_reader :playing
13
+
14
+ def initialize(sources)
15
+ @sources = sources
16
+ reset_state
17
+ end
18
+
19
+ def play(tempo: 120, string: nil, matrix: nil, limit: nil)
20
+ @limit = limit
21
+ if @playing
22
+ raise( StandardError,
23
+ "A sound player received #play when it was not in a stopped state.
24
+ Use multiple instances instead"
25
+ )
26
+ end
27
+ if matrix
28
+ play_from_matrix(tempo: tempo, matrix: matrix)
29
+ else
30
+ play_from_string(tempo: tempo, string: string)
31
+ end
32
+ @playing = true
33
+ end
34
+
35
+ def stop
36
+ reset_state
37
+ @thread&.kill
38
+ end
39
+
40
+ def reset_state
41
+ @thread = nil
42
+ @playing = false # It can't be called multiple times at once.
43
+ # Use multiple instances instead.
44
+ # There's a check to precaution against this.
45
+ @row_lengths = []
46
+ @active_steps = []
47
+ @steps_played = 0
48
+ @limit = nil # an upper limit for steps_played, defaults to no limit
49
+ end
50
+
51
+ private
52
+
53
+ def build_matrix_from_string(string)
54
+ string.tr(" ", '').split("\n").map(&:chars).map do |chars|
55
+ chars.map do |char|
56
+ if char == hit_char then 1
57
+ elsif char == rest_char then nil
58
+ else
59
+ raise( StandardError,
60
+ "
61
+ Error playing from string. Found char #{char} which is not
62
+ one of '#{hit_char}' or '#{rest_char}'.
63
+ "
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def play_from_string(tempo:, string:)
71
+ raise(
72
+ StandardError,
73
+ "one of :string or :matrix must be provided for SoundPlayer#play"
74
+ ) if !string
75
+ matrix = build_matrix_from_string(string)
76
+ play_from_matrix tempo: tempo, matrix: matrix
77
+ end
78
+
79
+ def play_from_matrix(tempo:, matrix:)
80
+ init_matrix_state matrix
81
+ rest_time = calculate_rest_time(tempo)
82
+ @thread = Thread.new do
83
+ loop do
84
+ if @limit && (@steps_played >= @limit)
85
+ stop # Thread kills itself, no need to break from the loop
86
+ end
87
+ matrix.each_with_index do |row, row_idx|
88
+ play_next_step_in_row row, row_idx
89
+ end
90
+ @steps_played += 1
91
+ sleep rest_time
92
+ end
93
+ end
94
+ end
95
+
96
+ def play_next_step_in_row row, row_idx
97
+ Thread.new do
98
+ row_length = @row_lengths[row_idx]
99
+ active_step = @active_steps[row_idx]
100
+ Thread.stop unless active_step
101
+ step_content = row[active_step]
102
+ if step_content
103
+ path = @sources[row_idx]
104
+ Thread.new { `mpg123 #{path} 2> /dev/null` }
105
+ end
106
+ @active_steps[row_idx] += 1
107
+ if @active_steps[row_idx] >= row_length
108
+ @active_steps[row_idx] = 0
109
+ end
110
+ end
111
+ end
112
+
113
+ def calculate_rest_time(tempo)
114
+ # Tempo is seen as quarter notes, i.e. at 120 BPM there's 0.5 seconds
115
+ # between steps
116
+ 60.0 / tempo.to_f
117
+ end
118
+
119
+ def init_matrix_state(matrix)
120
+ @row_lengths = matrix.map(&:length)
121
+ @active_steps = Array.new((matrix.length), 0)
122
+ end
123
+
124
+ def hit_char
125
+ self.class::HitChar
126
+ end
127
+
128
+ def rest_char
129
+ self.class::RestChar
130
+ end
131
+
132
+ end
@@ -0,0 +1,110 @@
1
+ class StepSequencer::Tests::TestCases
2
+
3
+ class Player
4
+ extend StepSequencer::Tests::TestCaseHelpers
5
+
6
+
7
+ def self.play_a_simple_grid_from_string
8
+ player = build_player %w{blip_1 blip_1}.map(&method(:asset_path))
9
+ player.play(
10
+ tempo: 240,
11
+ limit: 16,
12
+ string: <<-TXT,
13
+ x _ x _
14
+ _ _ x _
15
+ TXT
16
+ )
17
+ sleep 0.5 while player.playing
18
+ end
19
+
20
+ def self.play_a_polyrhythmic_string
21
+ player = build_player %w{blip_1 blip_1}.map(&method(:asset_path))
22
+ player.play(
23
+ tempo: 240,
24
+ limit: 16,
25
+ string: <<-TXT,
26
+ x _ _
27
+ x _ _ _
28
+ TXT
29
+ )
30
+ sleep 0.5 while player.playing
31
+ end
32
+
33
+ end
34
+
35
+ class Builder
36
+
37
+ extend StepSequencer::Tests::TestCaseHelpers
38
+
39
+ def self.build_c_major_chord
40
+ scale = equal_temperament_notes_with_speed_correction[0]
41
+ result_path = builder.build(
42
+ sources: scale.values_at(0, 4, 7),
43
+ effect: :Overlay
44
+ )
45
+ [result_path]
46
+ end
47
+
48
+ def self.build_f_sharp_major_chord
49
+ scale = equal_temperament_notes_with_speed_correction[0]
50
+ result_path = builder.build(
51
+ sources: scale.values_at(1, 6, 10),
52
+ effect: :Overlay
53
+ )
54
+ [result_path]
55
+ end
56
+
57
+ def self.loop_a_sound_4_point_5_times
58
+ builder.build(
59
+ sources: [asset_path("blip_1")],
60
+ effect: :Loop,
61
+ args: [times: 4.5]
62
+ )
63
+ end
64
+
65
+ def self.equal_temperament_notes_combined
66
+ scale_sources = equal_temperament_notes_with_speed_correction[0]
67
+ result_path = builder.build(
68
+ sources: scale_sources,
69
+ effect: :Combine
70
+ )
71
+ [result_path]
72
+ end
73
+
74
+ def self.equal_temperament_notes_with_speed_correction
75
+ builder.build(
76
+ sources: [asset_path("blip_1")],
77
+ effect: :Scale,
78
+ args: [scale: :equal_temperament]
79
+ )
80
+ end
81
+
82
+ def self.equal_temperament_with_no_speed_correction
83
+ builder.build(
84
+ sources: [asset_path("blip_1")],
85
+ effect: :Scale,
86
+ args: [scale: :equal_temperament, speed_correction: false]
87
+ )
88
+ end
89
+
90
+ def self.inverse_equal_temperament
91
+ builder.build(
92
+ sources: [asset_path("blip_1")],
93
+ effect: :Scale,
94
+ args: [scale: :equal_temperament, inverse: true]
95
+ )
96
+ end
97
+
98
+ def self.increase_gain
99
+ [0.25, 0.5, 0.75, 1, 1.5, 3, 5, 10].map do |val|
100
+ builder.build(
101
+ sources: [asset_path("blip_1")],
102
+ effect: :Gain,
103
+ args: [value: val]
104
+ )
105
+ end
106
+ end
107
+
108
+ end
109
+
110
+ end
@@ -0,0 +1,104 @@
1
+ require 'espeak'
2
+ require 'method_source'
3
+
4
+ # =============================================================================
5
+ # A custom test runner. The tests themselves are in test_cases.rb
6
+ # Usage: StepSequencer::Tests.run
7
+ # The private method run_test_collection can also be used; it's more modular.
8
+ # =============================================================================
9
+
10
+ class StepSequencer::Tests
11
+
12
+ # Runs all the test cases, speaking the method name and playing the result.
13
+ #
14
+ # Returns the results from the tests, for manual inspection from Pry
15
+ #
16
+ def self.run
17
+ cleanup
18
+ run_test_collection(builder_tests) do |fn_name, test_case|
19
+ result = run_test_case(builder_tests, fn_name, test_case)
20
+ result.tap &method(:play_sounds)
21
+ end
22
+ run_test_collection(player_tests) do |fn_name, test_case|
23
+ run_test_case(player_tests, fn_name, test_case)
24
+ end
25
+ end
26
+
27
+ # shared 'around' hook for tests
28
+ def self.run_test_case(test_class, fn_name, test_case)
29
+ speak_fn_name fn_name
30
+ puts fn_name
31
+ test_class.method(fn_name).source.display
32
+ test_case.call
33
+ end
34
+
35
+ class << self
36
+ private
37
+
38
+ def builder_tests
39
+ StepSequencer::Tests::TestCases::Builder
40
+ end
41
+ def player_tests
42
+ StepSequencer::Tests::TestCases::Player
43
+ end
44
+
45
+ def speak_fn_name fn_name
46
+ say fn_name.to_s.gsub("_", " ")
47
+ end
48
+
49
+ def cleanup
50
+ dir = StepSequencer::SoundBuilder::OutputDir
51
+ Dir.glob("#{dir}/*.mp3").each &File.method(:delete)
52
+ end
53
+
54
+ # Runs all the methods in a class, optionally filtered via :only and
55
+ # :accept options (arrays of symbols referencing methods of klass).
56
+ #
57
+ # If a block is provided, then it is passed the name as a symbol and
58
+ # the test case as a proc. The proc will need to be called manually
59
+ # from the block, and the block should return the result.
60
+ # This allows a before/after hook to be inserted.
61
+ # With no given block, the test case is automatically run.
62
+ #
63
+ # Returns a hash mapping test case names (symbols) to results
64
+ #
65
+ def run_test_collection(klass, only: nil, except: nil, &blk)
66
+ klass.methods(false).reduce({}) do |memo, fn_name|
67
+ next memo if only && !only.include?(fn_name)
68
+ next memo if except && except.include?(fn_name)
69
+ memo[fn_name] = if blk
70
+ blk.call(fn_name, ->{klass.send fn_name})
71
+ else
72
+ klass.send(fn_name)
73
+ end
74
+ memo
75
+ end
76
+ end
77
+
78
+ def say(phrase)
79
+ ESpeak::Speech.new(phrase).speak
80
+ end
81
+ def play_sounds(sounds, tempo: 800)
82
+ sleep_time = 60.0 / tempo.to_f
83
+ sounds.flatten.each do |path|
84
+ `mpg123 #{path} 2> /dev/null`
85
+ sleep sleep_time
86
+ end
87
+ end
88
+ end
89
+
90
+ # Helpers made available to test cases (if they include the module)
91
+ module TestCaseHelpers
92
+ private
93
+ def asset_path(name)
94
+ Gem.find_files("step_sequencer/test_assets/#{name}.mp3")[0]
95
+ end
96
+ def builder
97
+ StepSequencer::SoundBuilder
98
+ end
99
+ def build_player(sources)
100
+ StepSequencer::SoundPlayer.new sources
101
+ end
102
+ end
103
+
104
+ end
@@ -0,0 +1,33 @@
1
+ require 'byebug'
2
+ require 'securerandom'
3
+
4
+ Thread.abort_on_exception = true
5
+
6
+ # only top-level constant defined by the gem
7
+ class StepSequencer; end
8
+
9
+ # Require the refinements file first
10
+ refinements_file = Gem.find_files("step_sequencer/refinements.rb").shift
11
+ require refinements_file
12
+
13
+ # Add a little util method to access the refinements from other files.
14
+ class StepSequencer
15
+ def self.refinement(name)
16
+ StepSequencer::Refinements.const_get name
17
+ end
18
+ end
19
+
20
+ # Require all ruby files in lib/step_sequencer, ordered by depth
21
+ # This loads the refinements file again but it's no big deal
22
+ Gem.find_files("step_sequencer/**/*.rb")
23
+ .sort_by { |path| path.count "/" }
24
+ .each &method(:require)
25
+
26
+ # Add the default effects to the sound builder
27
+ StepSequencer::SoundBuilder.class_exec do
28
+ self::EffectsComponents = self::DefaultEffects.constants
29
+ .reduce({}) do |memo, name|
30
+ memo.tap { memo[name] = self::DefaultEffects.const_get name }
31
+ end
32
+ end
33
+
data/lib/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module StepSequencer
2
+ VERSION = '1.0.0'
3
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: step_sequencer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - max pleaner
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: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.19'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.19'
27
+ - !ruby/object:Gem::Dependency
28
+ name: method_source
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: espeak-ruby
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1'
55
+ description: ''
56
+ email: maxpleaner@gmail.com
57
+ executables:
58
+ - step_sequencer
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - README.md
63
+ - bin/step_sequencer
64
+ - lib/step_sequencer.rb
65
+ - lib/step_sequencer/example.rb
66
+ - lib/step_sequencer/refinements.rb
67
+ - lib/step_sequencer/sound_builder.rb
68
+ - lib/step_sequencer/sound_builder/default_effects.rb
69
+ - lib/step_sequencer/sound_builder/default_effects/combine.rb
70
+ - lib/step_sequencer/sound_builder/default_effects/gain.rb
71
+ - lib/step_sequencer/sound_builder/default_effects/loop.rb
72
+ - lib/step_sequencer/sound_builder/default_effects/overlay.rb
73
+ - lib/step_sequencer/sound_builder/default_effects/pitch.rb
74
+ - lib/step_sequencer/sound_builder/default_effects/scale.rb
75
+ - lib/step_sequencer/sound_builder/default_effects/slice.rb
76
+ - lib/step_sequencer/sound_builder/default_effects/speed.rb
77
+ - lib/step_sequencer/sound_builder/effects_component_protocol.rb
78
+ - lib/step_sequencer/sound_player.rb
79
+ - lib/step_sequencer/test_assets/blip_1.mp3
80
+ - lib/step_sequencer/test_assets/blip_2.mp3
81
+ - lib/step_sequencer/tests.rb
82
+ - lib/step_sequencer/tests/test_cases.rb
83
+ - lib/version.rb
84
+ homepage: http://github.com/maxpleaner/step_sequencer
85
+ licenses:
86
+ - MIT
87
+ metadata: {}
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.3'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 2.6.11
102
+ requirements: []
103
+ rubyforge_project:
104
+ rubygems_version: 2.6.11
105
+ signing_key:
106
+ specification_version: 4
107
+ summary: step sequencer (music tool)
108
+ test_files: []