step_sequencer 1.0.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.
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: []