wavify 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +204 -22
- data/exe/wavify +6 -0
- data/lib/wavify/adapters.rb +81 -0
- data/lib/wavify/audio.rb +1066 -120
- data/lib/wavify/cli.rb +237 -0
- data/lib/wavify/codecs/aiff.rb +388 -87
- data/lib/wavify/codecs/base.rb +98 -5
- data/lib/wavify/codecs/flac.rb +660 -170
- data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
- data/lib/wavify/codecs/raw.rb +235 -45
- data/lib/wavify/codecs/registry.rb +272 -10
- data/lib/wavify/codecs/wav.rb +453 -74
- data/lib/wavify/core/duration.rb +54 -2
- data/lib/wavify/core/format.rb +96 -11
- data/lib/wavify/core/sample_buffer.rb +648 -60
- data/lib/wavify/core/stream.rb +590 -34
- data/lib/wavify/dsl.rb +712 -100
- data/lib/wavify/dsp/automation.rb +109 -0
- data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
- data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
- data/lib/wavify/dsp/effects/chorus.rb +15 -4
- data/lib/wavify/dsp/effects/compressor.rb +133 -23
- data/lib/wavify/dsp/effects/delay.rb +46 -1
- data/lib/wavify/dsp/effects/distortion.rb +3 -2
- data/lib/wavify/dsp/effects/effect_base.rb +69 -2
- data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
- data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
- data/lib/wavify/dsp/effects/eq.rb +102 -0
- data/lib/wavify/dsp/effects/expander.rb +90 -0
- data/lib/wavify/dsp/effects/flanger.rb +112 -0
- data/lib/wavify/dsp/effects/limiter.rb +259 -0
- data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
- data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
- data/lib/wavify/dsp/effects/phaser.rb +112 -0
- data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
- data/lib/wavify/dsp/effects/reverb.rb +100 -14
- data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
- data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
- data/lib/wavify/dsp/effects/tremolo.rb +66 -0
- data/lib/wavify/dsp/effects/vibrato.rb +102 -0
- data/lib/wavify/dsp/effects.rb +106 -0
- data/lib/wavify/dsp/envelope.rb +90 -19
- data/lib/wavify/dsp/filter.rb +149 -8
- data/lib/wavify/dsp/headroom.rb +57 -0
- data/lib/wavify/dsp/lfo.rb +65 -0
- data/lib/wavify/dsp/loudness_meter.rb +281 -0
- data/lib/wavify/dsp/oscillator.rb +261 -22
- data/lib/wavify/dsp/processor.rb +90 -0
- data/lib/wavify/errors.rb +2 -2
- data/lib/wavify/sequencer/engine.rb +405 -89
- data/lib/wavify/sequencer/note_sequence.rb +45 -7
- data/lib/wavify/sequencer/pattern.rb +121 -12
- data/lib/wavify/sequencer/track.rb +149 -10
- data/lib/wavify/version.rb +1 -1
- data/lib/wavify.rb +18 -0
- data/sig/audio.rbs +86 -0
- data/sig/codecs.rbs +77 -0
- data/sig/core.rbs +108 -0
- data/sig/dsl.rbs +32 -0
- data/sig/dsp.rbs +87 -0
- data/sig/effects.rbs +129 -0
- data/sig/public_api.yml +111 -0
- data/sig/sequencer.rbs +126 -0
- data/sig/stream.rbs +30 -0
- data/sig/wavify.rbs +24 -0
- metadata +44 -58
- data/.serena/.gitignore +0 -1
- data/.serena/memories/project_overview.md +0 -5
- data/.serena/memories/style_and_completion.md +0 -5
- data/.serena/memories/suggested_commands.md +0 -11
- data/.serena/project.yml +0 -126
- data/.simplecov +0 -18
- data/.yardopts +0 -4
- data/Rakefile +0 -190
- data/benchmarks/README.md +0 -46
- data/benchmarks/benchmark_helper.rb +0 -112
- data/benchmarks/dsp_effects_benchmark.rb +0 -46
- data/benchmarks/flac_benchmark.rb +0 -74
- data/benchmarks/streaming_memory_benchmark.rb +0 -94
- data/benchmarks/wav_io_benchmark.rb +0 -110
- data/examples/audio_processing.rb +0 -73
- data/examples/cinematic_transition.rb +0 -118
- data/examples/drum_machine.rb +0 -74
- data/examples/format_convert.rb +0 -81
- data/examples/hybrid_arrangement.rb +0 -165
- data/examples/streaming_master_chain.rb +0 -129
- data/examples/synth_pad.rb +0 -42
- data/tools/fixture_writer.rb +0 -85
|
@@ -28,18 +28,25 @@ module Wavify
|
|
|
28
28
|
}.freeze
|
|
29
29
|
|
|
30
30
|
# Parsed note event (`midi_note` is `nil` for rests).
|
|
31
|
-
Event = Struct.new(:index, :token, :midi_note, keyword_init: true) do
|
|
31
|
+
Event = Struct.new(:index, :token, :midi_note, :duration_denominator, :duration_multiplier, :tie, keyword_init: true) do
|
|
32
32
|
def rest?
|
|
33
33
|
midi_note.nil?
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
def tie?
|
|
37
|
+
tie
|
|
38
|
+
end
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
attr_reader :default_octave, :events, :notation
|
|
38
42
|
|
|
39
43
|
def initialize(notation, default_octave: 4)
|
|
40
|
-
|
|
44
|
+
raise InvalidNoteError, "note sequence notation must be String" unless notation.is_a?(String)
|
|
45
|
+
|
|
46
|
+
@notation = notation.dup.freeze
|
|
41
47
|
@default_octave = validate_default_octave!(default_octave)
|
|
42
|
-
@events = parse_events(notation).freeze
|
|
48
|
+
@events = parse_events(@notation).each(&:freeze).freeze
|
|
49
|
+
freeze
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
# Enumerates parsed events.
|
|
@@ -93,16 +100,47 @@ module Wavify
|
|
|
93
100
|
raise InvalidNoteError, "note sequence notation must not be empty" if tokens.empty?
|
|
94
101
|
|
|
95
102
|
tokens.each_with_index.map do |token, index|
|
|
96
|
-
|
|
103
|
+
midi_note, duration_denominator, duration_multiplier, tie = parse_token(token)
|
|
104
|
+
Event.new(
|
|
105
|
+
index: index,
|
|
106
|
+
token: token,
|
|
107
|
+
midi_note: midi_note,
|
|
108
|
+
duration_denominator: duration_denominator,
|
|
109
|
+
duration_multiplier: duration_multiplier,
|
|
110
|
+
tie: tie
|
|
111
|
+
)
|
|
97
112
|
end
|
|
98
113
|
end
|
|
99
114
|
|
|
100
115
|
def parse_token(token)
|
|
101
|
-
|
|
116
|
+
tie = token.end_with?("~")
|
|
117
|
+
body = tie ? token.delete_suffix("~") : token
|
|
118
|
+
body, duration_denominator, duration_multiplier = split_duration_suffix(body)
|
|
119
|
+
|
|
120
|
+
return [nil, duration_denominator, duration_multiplier, tie] if body == "."
|
|
121
|
+
|
|
122
|
+
midi_note = body.match?(/\A-?\d+\z/) ? parse_midi_number(body) : parse_note_name(body)
|
|
123
|
+
[midi_note, duration_denominator, duration_multiplier, tie]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def split_duration_suffix(token)
|
|
127
|
+
body, denominator = token.split("/", 2)
|
|
128
|
+
return [body, nil, 1.0] unless denominator
|
|
129
|
+
|
|
130
|
+
match = denominator.match(/\A(\d+)([.tT]?)\z/)
|
|
131
|
+
raise InvalidNoteError, "invalid duration suffix: #{token.inspect}" unless match
|
|
102
132
|
|
|
103
|
-
|
|
133
|
+
value = Integer(match[1])
|
|
134
|
+
raise InvalidNoteError, "duration denominator must be positive: #{token.inspect}" unless value.positive?
|
|
104
135
|
|
|
105
|
-
|
|
136
|
+
multiplier = case match[2]
|
|
137
|
+
when "." then 1.5
|
|
138
|
+
when "t", "T" then 2.0 / 3.0
|
|
139
|
+
else 1.0
|
|
140
|
+
end
|
|
141
|
+
[body, value, multiplier]
|
|
142
|
+
rescue ArgumentError
|
|
143
|
+
raise InvalidNoteError, "invalid duration suffix: #{token.inspect}"
|
|
106
144
|
end
|
|
107
145
|
|
|
108
146
|
def parse_midi_number(token)
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Wavify
|
|
4
4
|
module Sequencer
|
|
5
|
-
# Step-pattern parser (`x`, `X`, `-`, `.`) for trigger sequencing.
|
|
5
|
+
# Step-pattern parser (`x`, `X`, `x0.7`, `x?50`, `x:3`, `-`, `.`) for trigger sequencing.
|
|
6
6
|
class Pattern
|
|
7
7
|
include Enumerable
|
|
8
8
|
|
|
9
|
+
# Highest supported step-grid resolution per bar.
|
|
10
|
+
MAX_RESOLUTION = 4_096
|
|
11
|
+
# Highest supported retrigger count for one pattern step.
|
|
12
|
+
MAX_RATCHET = 64
|
|
13
|
+
|
|
9
14
|
# Parsed pattern step value object.
|
|
10
|
-
Step = Struct.new(:index, :trigger, :accent, :symbol, :velocity, keyword_init: true) do
|
|
15
|
+
Step = Struct.new(:index, :trigger, :accent, :symbol, :velocity, :probability, :ratchet, keyword_init: true) do
|
|
11
16
|
def rest?
|
|
12
17
|
!trigger
|
|
13
18
|
end
|
|
@@ -24,9 +29,15 @@ module Wavify
|
|
|
24
29
|
attr_reader :resolution, :steps, :notation
|
|
25
30
|
|
|
26
31
|
def initialize(notation, resolution: 16)
|
|
27
|
-
|
|
32
|
+
raise InvalidPatternError, "pattern notation must be String" unless notation.is_a?(String)
|
|
33
|
+
|
|
34
|
+
@notation = notation.dup.freeze
|
|
28
35
|
@resolution = validate_resolution!(resolution)
|
|
29
|
-
@steps = parse_steps(notation).freeze
|
|
36
|
+
@steps = parse_steps(@notation).freeze
|
|
37
|
+
if @steps.length > @resolution
|
|
38
|
+
raise InvalidPatternError, "pattern has #{@steps.length} steps but resolution is #{@resolution}"
|
|
39
|
+
end
|
|
40
|
+
freeze
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
# Enumerates parsed steps.
|
|
@@ -73,7 +84,9 @@ module Wavify
|
|
|
73
84
|
private
|
|
74
85
|
|
|
75
86
|
def validate_resolution!(value)
|
|
76
|
-
|
|
87
|
+
unless value.is_a?(Integer) && value.between?(1, MAX_RESOLUTION)
|
|
88
|
+
raise InvalidPatternError, "resolution must be an Integer between 1 and #{MAX_RESOLUTION}"
|
|
89
|
+
end
|
|
77
90
|
|
|
78
91
|
value
|
|
79
92
|
end
|
|
@@ -81,21 +94,117 @@ module Wavify
|
|
|
81
94
|
def parse_steps(notation)
|
|
82
95
|
raise InvalidPatternError, "pattern notation must be String" unless notation.is_a?(String)
|
|
83
96
|
|
|
84
|
-
chars = notation.each_char.reject { |char| char =~ /\s/ || char == "|" }
|
|
97
|
+
chars = notation.each_char.reject { |char| char =~ /\s/ || char == "|" }.join
|
|
85
98
|
raise InvalidPatternError, "pattern notation must not be empty" if chars.empty?
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
steps = []
|
|
101
|
+
cursor = 0
|
|
102
|
+
while cursor < chars.length
|
|
103
|
+
char = chars[cursor]
|
|
104
|
+
index = steps.length
|
|
105
|
+
|
|
88
106
|
case char
|
|
89
|
-
when "x"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
when "x", "X"
|
|
108
|
+
cursor += 1
|
|
109
|
+
velocity_text, cursor = scan_velocity_suffix(chars, cursor)
|
|
110
|
+
probability, ratchet, cursor = scan_trigger_modifiers(chars, cursor, index)
|
|
111
|
+
default_velocity = char == "X" ? 1.0 : 0.8
|
|
112
|
+
velocity = velocity_text ? parse_velocity!(velocity_text, index) : default_velocity
|
|
113
|
+
steps << Step.new(
|
|
114
|
+
index: index,
|
|
115
|
+
trigger: true,
|
|
116
|
+
accent: char == "X",
|
|
117
|
+
symbol: char,
|
|
118
|
+
velocity: velocity,
|
|
119
|
+
probability: probability,
|
|
120
|
+
ratchet: ratchet
|
|
121
|
+
).freeze
|
|
93
122
|
when "-", "."
|
|
94
|
-
Step.new(
|
|
123
|
+
steps << Step.new(
|
|
124
|
+
index: index, trigger: false, accent: false, symbol: char, velocity: 0.0, probability: 0.0, ratchet: 1
|
|
125
|
+
).freeze
|
|
126
|
+
cursor += 1
|
|
95
127
|
else
|
|
96
128
|
raise InvalidPatternError, "invalid pattern symbol #{char.inspect} at step #{index}"
|
|
97
129
|
end
|
|
98
130
|
end
|
|
131
|
+
|
|
132
|
+
steps
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scan_velocity_suffix(chars, cursor)
|
|
136
|
+
return [nil, cursor] unless chars[cursor]&.match?(/\d/)
|
|
137
|
+
|
|
138
|
+
start = cursor
|
|
139
|
+
cursor += 1
|
|
140
|
+
cursor += 1 while cursor < chars.length && chars[cursor].match?(/\d/)
|
|
141
|
+
if chars[cursor] == "."
|
|
142
|
+
cursor += 1
|
|
143
|
+
cursor += 1 while cursor < chars.length && chars[cursor].match?(/\d/)
|
|
144
|
+
end
|
|
145
|
+
[chars[start...cursor], cursor]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def scan_trigger_modifiers(chars, cursor, index)
|
|
149
|
+
probability = 1.0
|
|
150
|
+
ratchet = 1
|
|
151
|
+
seen = {}
|
|
152
|
+
|
|
153
|
+
while cursor < chars.length && ["?", ":"].include?(chars[cursor])
|
|
154
|
+
modifier = chars[cursor]
|
|
155
|
+
raise InvalidPatternError, "duplicate modifier #{modifier.inspect} at step #{index}" if seen[modifier]
|
|
156
|
+
|
|
157
|
+
seen[modifier] = true
|
|
158
|
+
case modifier
|
|
159
|
+
when "?"
|
|
160
|
+
text, cursor = scan_numeric_modifier(chars, cursor + 1, "probability", index)
|
|
161
|
+
probability = parse_probability!(text, index)
|
|
162
|
+
when ":"
|
|
163
|
+
text, cursor = scan_numeric_modifier(chars, cursor + 1, "ratchet", index)
|
|
164
|
+
ratchet = parse_ratchet!(text, index)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[probability, ratchet, cursor]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def scan_numeric_modifier(chars, cursor, name, index)
|
|
172
|
+
start = cursor
|
|
173
|
+
cursor += 1 while cursor < chars.length && chars[cursor].match?(/\d/)
|
|
174
|
+
if chars[cursor] == "."
|
|
175
|
+
cursor += 1
|
|
176
|
+
cursor += 1 while cursor < chars.length && chars[cursor].match?(/\d/)
|
|
177
|
+
end
|
|
178
|
+
raise InvalidPatternError, "missing #{name} value at step #{index}" if start == cursor
|
|
179
|
+
|
|
180
|
+
[chars[start...cursor], cursor]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_velocity!(text, index)
|
|
184
|
+
velocity = Float(text)
|
|
185
|
+
return velocity if velocity.between?(0.0, 1.0)
|
|
186
|
+
|
|
187
|
+
raise InvalidPatternError, "velocity must be between 0.0 and 1.0 at step #{index}"
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
raise InvalidPatternError, "invalid velocity #{text.inspect} at step #{index}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_probability!(text, index)
|
|
193
|
+
probability = Float(text) / 100.0
|
|
194
|
+
return probability if probability.between?(0.0, 1.0)
|
|
195
|
+
|
|
196
|
+
raise InvalidPatternError, "probability must be between 0 and 100 at step #{index}"
|
|
197
|
+
rescue ArgumentError
|
|
198
|
+
raise InvalidPatternError, "invalid probability #{text.inspect} at step #{index}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_ratchet!(text, index)
|
|
202
|
+
ratchet = Integer(text)
|
|
203
|
+
return ratchet if ratchet.between?(1, MAX_RATCHET)
|
|
204
|
+
|
|
205
|
+
raise InvalidPatternError, "ratchet must be between 1 and #{MAX_RATCHET} at step #{index}"
|
|
206
|
+
rescue ArgumentError
|
|
207
|
+
raise InvalidPatternError, "invalid ratchet #{text.inspect} at step #{index}"
|
|
99
208
|
end
|
|
100
209
|
end
|
|
101
210
|
end
|
|
@@ -11,7 +11,7 @@ module Wavify
|
|
|
11
11
|
"MAJ" => [0, 4, 7],
|
|
12
12
|
"MAJ7" => [0, 4, 7, 11],
|
|
13
13
|
"7" => [0, 4, 7, 10],
|
|
14
|
-
"M7" => [0,
|
|
14
|
+
"M7" => [0, 4, 7, 11],
|
|
15
15
|
"MIN" => [0, 3, 7],
|
|
16
16
|
"MIN7" => [0, 3, 7, 10],
|
|
17
17
|
"MIN9" => [0, 3, 7, 10, 14],
|
|
@@ -37,8 +37,17 @@ module Wavify
|
|
|
37
37
|
"aug" => [0, 4, 8]
|
|
38
38
|
).freeze
|
|
39
39
|
|
|
40
|
+
SCALE_INTERVALS = {
|
|
41
|
+
major: [0, 2, 4, 5, 7, 9, 11],
|
|
42
|
+
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
43
|
+
chromatic: (0..11).to_a,
|
|
44
|
+
pentatonic_major: [0, 2, 4, 7, 9],
|
|
45
|
+
pentatonic_minor: [0, 3, 5, 7, 10]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
40
48
|
attr_reader :name, :pattern, :note_sequence, :chord_progression, :waveform, :gain_db, :pan_position,
|
|
41
|
-
:pattern_resolution, :note_resolution, :default_octave, :envelope, :effects
|
|
49
|
+
:pattern_resolution, :note_resolution, :default_octave, :envelope, :effects, :key_root, :scale,
|
|
50
|
+
:chord_voicing, :synth_options
|
|
42
51
|
|
|
43
52
|
def initialize(name, **options)
|
|
44
53
|
@name = validate_name!(name)
|
|
@@ -50,14 +59,19 @@ module Wavify
|
|
|
50
59
|
@note_resolution = validate_resolution!(note_resolution, :note_resolution)
|
|
51
60
|
@default_octave = validate_default_octave!(default_octave)
|
|
52
61
|
@waveform = options.fetch(:waveform, :sine).to_sym
|
|
62
|
+
@synth_options = validate_synth_options!(options.fetch(:synth_options, {}))
|
|
53
63
|
@gain_db = validate_numeric!(options.fetch(:gain_db, 0.0), :gain_db).to_f
|
|
54
64
|
@pan_position = validate_pan!(options.fetch(:pan_position, 0.0))
|
|
55
65
|
@envelope = validate_envelope!(options[:envelope])
|
|
56
66
|
@effects = validate_effects!(options.fetch(:effects, []))
|
|
67
|
+
@key_root = normalize_key_root(options[:key])
|
|
68
|
+
@scale = normalize_scale(options[:scale])
|
|
69
|
+
@chord_voicing = normalize_chord_voicing(options[:chord_voicing])
|
|
57
70
|
|
|
58
71
|
@pattern = coerce_pattern(options[:pattern])
|
|
59
72
|
@note_sequence = coerce_note_sequence(options[:note_sequence])
|
|
60
73
|
@chord_progression = coerce_chord_progression(options[:chord_progression])
|
|
74
|
+
freeze
|
|
61
75
|
end
|
|
62
76
|
|
|
63
77
|
# Returns a copy with a new pattern.
|
|
@@ -86,6 +100,23 @@ module Wavify
|
|
|
86
100
|
copy(chord_progression: chords, default_octave: default_octave)
|
|
87
101
|
end
|
|
88
102
|
|
|
103
|
+
# Returns a copy with scale quantization settings.
|
|
104
|
+
#
|
|
105
|
+
# @param key [Symbol, String, nil]
|
|
106
|
+
# @param scale [Symbol, String, nil]
|
|
107
|
+
# @return [Track]
|
|
108
|
+
def with_key(key, scale: @scale)
|
|
109
|
+
copy(key: key, scale: scale)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns a copy with chord voicing settings.
|
|
113
|
+
#
|
|
114
|
+
# @param voicing [Symbol, String, nil]
|
|
115
|
+
# @return [Track]
|
|
116
|
+
def with_chord_voicing(voicing)
|
|
117
|
+
copy(chord_voicing: voicing)
|
|
118
|
+
end
|
|
119
|
+
|
|
89
120
|
# Returns a copy with a different oscillator waveform.
|
|
90
121
|
#
|
|
91
122
|
# @param waveform [Symbol, String]
|
|
@@ -156,13 +187,17 @@ module Wavify
|
|
|
156
187
|
note_sequence: overrides.fetch(:note_sequence, @note_sequence),
|
|
157
188
|
chord_progression: overrides.fetch(:chord_progression, @chord_progression),
|
|
158
189
|
waveform: overrides.fetch(:waveform, @waveform),
|
|
190
|
+
synth_options: overrides.fetch(:synth_options, @synth_options),
|
|
159
191
|
gain_db: overrides.fetch(:gain_db, @gain_db),
|
|
160
192
|
pan_position: overrides.fetch(:pan_position, @pan_position),
|
|
161
193
|
pattern_resolution: overrides.fetch(:pattern_resolution, @pattern_resolution),
|
|
162
194
|
note_resolution: overrides.fetch(:note_resolution, @note_resolution),
|
|
163
195
|
default_octave: overrides.fetch(:default_octave, @default_octave),
|
|
164
196
|
envelope: overrides.fetch(:envelope, @envelope),
|
|
165
|
-
effects: overrides.fetch(:effects, @effects)
|
|
197
|
+
effects: overrides.fetch(:effects, @effects),
|
|
198
|
+
key: overrides.fetch(:key, @key_root),
|
|
199
|
+
scale: overrides.fetch(:scale, @scale),
|
|
200
|
+
chord_voicing: overrides.fetch(:chord_voicing, @chord_voicing)
|
|
166
201
|
)
|
|
167
202
|
end
|
|
168
203
|
|
|
@@ -190,26 +225,53 @@ module Wavify
|
|
|
190
225
|
end
|
|
191
226
|
|
|
192
227
|
def self.parse_chord_token(token, default_octave:)
|
|
193
|
-
|
|
228
|
+
chord_token, inline_voicing = token.to_s.split("@", 2)
|
|
229
|
+
match = chord_token.match(/\A([A-Ga-g])([#b]?)(.*)\z/)
|
|
194
230
|
raise InvalidNoteError, "invalid chord token: #{token.inspect}" unless match
|
|
195
231
|
|
|
196
232
|
root_name = "#{match[1].upcase}#{match[2]}"
|
|
197
|
-
suffix = match[3].to_s
|
|
233
|
+
suffix, bass_name = match[3].to_s.split("/", 2)
|
|
198
234
|
suffix_key = normalize_chord_suffix(suffix)
|
|
199
235
|
intervals = CHORD_INTERVALS[suffix_key] || CHORD_INTERVALS[suffix]
|
|
200
236
|
raise InvalidNoteError, "unsupported chord quality: #{suffix.inspect}" unless intervals
|
|
201
237
|
|
|
202
238
|
root_midi = NoteSequence.new("#{root_name}#{default_octave}", default_octave: default_octave).midi_notes.first
|
|
239
|
+
midi_notes = intervals.map { |interval| root_midi + interval }
|
|
240
|
+
midi_notes = apply_chord_inversion(midi_notes, bass_name, root_midi, default_octave) if bass_name
|
|
241
|
+
midi_notes = apply_chord_voicing(midi_notes, inline_voicing) if inline_voicing
|
|
203
242
|
{
|
|
204
243
|
token: token,
|
|
205
244
|
root_midi: root_midi,
|
|
206
|
-
midi_notes:
|
|
245
|
+
midi_notes: midi_notes
|
|
207
246
|
}
|
|
208
247
|
end
|
|
209
248
|
|
|
249
|
+
def self.apply_chord_inversion(midi_notes, bass_name, root_midi, default_octave)
|
|
250
|
+
bass = NoteSequence.new("#{bass_name}#{default_octave}", default_octave: default_octave).midi_notes.first
|
|
251
|
+
bass -= 12 while bass > root_midi
|
|
252
|
+
remaining = midi_notes.reject { |midi| midi % 12 == bass % 12 }
|
|
253
|
+
[bass, *remaining].sort
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def self.apply_chord_voicing(midi_notes, voicing)
|
|
257
|
+
case normalize_chord_voicing(voicing)
|
|
258
|
+
when nil, :root
|
|
259
|
+
midi_notes
|
|
260
|
+
when :open
|
|
261
|
+
midi_notes.each_with_index.map { |midi, index| index.odd? ? midi + 12 : midi }.sort
|
|
262
|
+
when :drop2
|
|
263
|
+
return midi_notes if midi_notes.length < 3
|
|
264
|
+
|
|
265
|
+
ordered = midi_notes.sort
|
|
266
|
+
dropped = ordered.delete_at(-2) - 12
|
|
267
|
+
[dropped, *ordered].sort
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
210
271
|
def self.normalize_chord_suffix(suffix)
|
|
211
272
|
value = suffix.to_s
|
|
212
273
|
return "" if value.empty?
|
|
274
|
+
return value.upcase if value.start_with?("M")
|
|
213
275
|
|
|
214
276
|
if value.start_with?("m") && !value.start_with?("maj")
|
|
215
277
|
"m#{value[1..]}"
|
|
@@ -220,6 +282,23 @@ module Wavify
|
|
|
220
282
|
|
|
221
283
|
private_class_method :normalize_chord_suffix
|
|
222
284
|
|
|
285
|
+
def quantize_midi_note(midi_note)
|
|
286
|
+
return midi_note unless @key_root && @scale
|
|
287
|
+
|
|
288
|
+
self.class.quantize_midi_note(midi_note, key: @key_root, scale: @scale)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def self.quantize_midi_note(midi_note, key:, scale:)
|
|
292
|
+
key_pitch = NoteSequence::NOTE_OFFSETS.fetch(key.to_s.upcase)
|
|
293
|
+
intervals = SCALE_INTERVALS.fetch(scale.to_sym)
|
|
294
|
+
octave = midi_note / 12
|
|
295
|
+
candidates = ((octave - 1)..(octave + 1)).flat_map do |candidate_octave|
|
|
296
|
+
base = (candidate_octave * 12) + key_pitch
|
|
297
|
+
intervals.map { |interval| base + interval }
|
|
298
|
+
end.select { |candidate| candidate.between?(0, 127) }
|
|
299
|
+
candidates.min_by { |candidate| [(candidate - midi_note).abs, candidate] } || midi_note
|
|
300
|
+
end
|
|
301
|
+
|
|
223
302
|
private
|
|
224
303
|
|
|
225
304
|
def validate_name!(name)
|
|
@@ -232,7 +311,9 @@ module Wavify
|
|
|
232
311
|
end
|
|
233
312
|
|
|
234
313
|
def validate_resolution!(value, name)
|
|
235
|
-
|
|
314
|
+
unless value.is_a?(Integer) && value.between?(1, Pattern::MAX_RESOLUTION)
|
|
315
|
+
raise SequencerError, "#{name} must be an Integer in 1..#{Pattern::MAX_RESOLUTION}"
|
|
316
|
+
end
|
|
236
317
|
|
|
237
318
|
value
|
|
238
319
|
end
|
|
@@ -244,13 +325,17 @@ module Wavify
|
|
|
244
325
|
end
|
|
245
326
|
|
|
246
327
|
def validate_numeric!(value, name)
|
|
247
|
-
|
|
328
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
|
|
329
|
+
raise SequencerError, "#{name} must be a finite Numeric"
|
|
330
|
+
end
|
|
248
331
|
|
|
249
332
|
value
|
|
250
333
|
end
|
|
251
334
|
|
|
252
335
|
def validate_pan!(value)
|
|
253
|
-
|
|
336
|
+
unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.between?(-1.0, 1.0)
|
|
337
|
+
raise SequencerError, "pan_position must be a finite Numeric in -1.0..1.0"
|
|
338
|
+
end
|
|
254
339
|
|
|
255
340
|
value.to_f
|
|
256
341
|
end
|
|
@@ -271,6 +356,52 @@ module Wavify
|
|
|
271
356
|
effects.freeze
|
|
272
357
|
end
|
|
273
358
|
|
|
359
|
+
def validate_synth_options!(value)
|
|
360
|
+
raise SequencerError, "synth_options must be a Hash" unless value.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
supported = %i[amplitude phase pulse_width detune unison]
|
|
363
|
+
unknown = value.keys - supported
|
|
364
|
+
raise SequencerError, "unsupported synth options: #{unknown.join(', ')}" unless unknown.empty?
|
|
365
|
+
|
|
366
|
+
value.dup.freeze
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def normalize_key_root(value)
|
|
370
|
+
return nil if value.nil?
|
|
371
|
+
|
|
372
|
+
key = value.to_s.upcase
|
|
373
|
+
raise SequencerError, "key must be a note name" unless NoteSequence::NOTE_OFFSETS.key?(key)
|
|
374
|
+
|
|
375
|
+
key
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def normalize_scale(value)
|
|
379
|
+
return nil if value.nil?
|
|
380
|
+
|
|
381
|
+
scale = value.to_sym
|
|
382
|
+
raise SequencerError, "unsupported scale: #{value.inspect}" unless SCALE_INTERVALS.key?(scale)
|
|
383
|
+
|
|
384
|
+
scale
|
|
385
|
+
rescue NoMethodError
|
|
386
|
+
raise SequencerError, "scale must be Symbol/String"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def normalize_chord_voicing(value)
|
|
390
|
+
self.class.send(:normalize_chord_voicing, value)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def self.normalize_chord_voicing(value)
|
|
394
|
+
return nil if value.nil?
|
|
395
|
+
|
|
396
|
+
voicing = value.to_sym
|
|
397
|
+
return voicing if %i[root open drop2].include?(voicing)
|
|
398
|
+
|
|
399
|
+
raise SequencerError, "unsupported chord voicing: #{value.inspect}"
|
|
400
|
+
rescue NoMethodError
|
|
401
|
+
raise SequencerError, "chord_voicing must be Symbol/String"
|
|
402
|
+
end
|
|
403
|
+
private_class_method :normalize_chord_voicing
|
|
404
|
+
|
|
274
405
|
def coerce_pattern(pattern)
|
|
275
406
|
return nil if pattern.nil?
|
|
276
407
|
return pattern if pattern.is_a?(Pattern)
|
|
@@ -288,10 +419,18 @@ module Wavify
|
|
|
288
419
|
def coerce_chord_progression(chord_progression)
|
|
289
420
|
return nil if chord_progression.nil?
|
|
290
421
|
if chord_progression.is_a?(Array) && chord_progression.all? { |item| item.is_a?(Hash) && item[:midi_notes] }
|
|
291
|
-
return chord_progression
|
|
422
|
+
return chord_progression.map { |chord| chord.transform_values { |item| item.is_a?(Array) ? item.dup.freeze : item }.freeze }.freeze
|
|
292
423
|
end
|
|
293
424
|
|
|
294
425
|
self.class.parse_chords(chord_progression, default_octave: @default_octave)
|
|
426
|
+
.map { |chord| apply_track_chord_options(chord).transform_values { |item| item.is_a?(Array) ? item.freeze : item }.freeze }
|
|
427
|
+
.freeze
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def apply_track_chord_options(chord)
|
|
431
|
+
midi_notes = chord.fetch(:midi_notes).map { |midi| quantize_midi_note(midi) }
|
|
432
|
+
midi_notes = self.class.apply_chord_voicing(midi_notes, @chord_voicing) if @chord_voicing
|
|
433
|
+
chord.merge(midi_notes: midi_notes)
|
|
295
434
|
end
|
|
296
435
|
end
|
|
297
436
|
end
|
data/lib/wavify/version.rb
CHANGED
data/lib/wavify.rb
CHANGED
|
@@ -5,7 +5,9 @@ require_relative "wavify/errors"
|
|
|
5
5
|
require_relative "wavify/core/format"
|
|
6
6
|
require_relative "wavify/core/duration"
|
|
7
7
|
require_relative "wavify/core/sample_buffer"
|
|
8
|
+
require_relative "wavify/dsp/processor"
|
|
8
9
|
require_relative "wavify/core/stream"
|
|
10
|
+
require_relative "wavify/adapters"
|
|
9
11
|
require_relative "wavify/codecs/base"
|
|
10
12
|
require_relative "wavify/codecs/raw"
|
|
11
13
|
require_relative "wavify/codecs/wav"
|
|
@@ -13,16 +15,32 @@ require_relative "wavify/codecs/flac"
|
|
|
13
15
|
require_relative "wavify/codecs/ogg_vorbis"
|
|
14
16
|
require_relative "wavify/codecs/aiff"
|
|
15
17
|
require_relative "wavify/codecs/registry"
|
|
18
|
+
require_relative "wavify/dsp/automation"
|
|
19
|
+
require_relative "wavify/dsp/lfo"
|
|
16
20
|
require_relative "wavify/dsp/oscillator"
|
|
17
21
|
require_relative "wavify/dsp/envelope"
|
|
18
22
|
require_relative "wavify/dsp/filter"
|
|
23
|
+
require_relative "wavify/dsp/loudness_meter"
|
|
24
|
+
require_relative "wavify/dsp/headroom"
|
|
19
25
|
require_relative "wavify/dsp/effects"
|
|
20
26
|
require_relative "wavify/sequencer"
|
|
21
27
|
require_relative "wavify/audio"
|
|
22
28
|
require_relative "wavify/dsl"
|
|
29
|
+
require_relative "wavify/cli"
|
|
23
30
|
|
|
24
31
|
##
|
|
25
32
|
# Wavify is a pure Ruby audio processing toolkit with immutable transforms,
|
|
26
33
|
# multiple codecs, DSP primitives, and a small sequencing DSL.
|
|
27
34
|
module Wavify
|
|
35
|
+
# @param value [Numeric]
|
|
36
|
+
# @return [Wavify::Core::Duration]
|
|
37
|
+
def self.seconds(value)
|
|
38
|
+
Core::Duration.new(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param value [Numeric] milliseconds
|
|
42
|
+
# @return [Wavify::Core::Duration]
|
|
43
|
+
def self.ms(value)
|
|
44
|
+
Core::Duration.new(value.to_f / 1000.0)
|
|
45
|
+
end
|
|
28
46
|
end
|
data/sig/audio.rbs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Wavify
|
|
2
|
+
class Audio
|
|
3
|
+
attr_reader buffer: Core::SampleBuffer
|
|
4
|
+
|
|
5
|
+
def self.read: (String | IO path_or_io, ?format: Core::Format?, ?codec_options: Hash[Symbol, untyped]?, ?strict: bool, ?filename: String?) -> Audio
|
|
6
|
+
def self.metadata: (String | IO path_or_io, ?format: Core::Format?, ?codec_options: Hash[Symbol, untyped]?, ?strict: bool, ?filename: String?) -> Hash[Symbol, untyped]
|
|
7
|
+
def self.info: (String | IO path_or_io, ?format: Core::Format?, ?codec_options: Hash[Symbol, untyped]?, ?strict: bool, ?filename: String?) -> Hash[Symbol, untyped]
|
|
8
|
+
def self.mix: (*Audio audios, ?strategy: Symbol, ?gains: Array[Numeric]?, ?align: Symbol, ?format: Core::Format?, ?work_format: Core::Format?, ?headroom_smoothing: Numeric) -> Audio
|
|
9
|
+
def self.stream: (String | IO path_or_io, ?chunk_size: Integer, ?format: Core::Format?, ?codec_options: Hash[Symbol, untyped]?, ?strict: bool, ?filename: String?) -> Core::Stream
|
|
10
|
+
def self.tone: (frequency: Numeric, duration: Numeric | Core::Duration, format: Core::Format, ?waveform: Symbol, **untyped oscillator_options) -> Audio
|
|
11
|
+
def self.silence: (Numeric | Core::Duration duration_seconds, format: Core::Format) -> Audio
|
|
12
|
+
|
|
13
|
+
def initialize: (Core::SampleBuffer buffer) -> void
|
|
14
|
+
def format: () -> Core::Format
|
|
15
|
+
def duration: () -> Core::Duration
|
|
16
|
+
def sample_frame_count: () -> Integer
|
|
17
|
+
def channels: () -> Integer
|
|
18
|
+
def sample_rate: () -> Integer
|
|
19
|
+
def bit_depth: () -> Integer
|
|
20
|
+
def with_bit_depth: (Integer value, ?dither: bool, ?dither_seed: Integer?) -> Audio
|
|
21
|
+
def frames: () -> Array[Array[Numeric]]
|
|
22
|
+
def each_frame: () { (Array[Numeric]) -> void } -> Audio
|
|
23
|
+
| () -> Enumerator[Array[Numeric], Audio]
|
|
24
|
+
def write: (String | IO path, ?format: Core::Format?, ?codec: Symbol | untyped, ?filename: String?, ?codec_options: Hash[Symbol, untyped]?, ?overwrite: bool) -> Audio
|
|
25
|
+
def convert: (Core::Format new_format, ?dither: bool, ?dither_seed: Integer?, ?resampler: Symbol) -> Audio
|
|
26
|
+
def resample: (sample_rate: Integer, ?resampler: Symbol) -> Audio
|
|
27
|
+
def to_mono: () -> Audio
|
|
28
|
+
def to_stereo: () -> Audio
|
|
29
|
+
def split: (at: Numeric | Core::Duration) -> [Audio, Audio]
|
|
30
|
+
def slice: (from: Numeric | Core::Duration, to: Numeric | Core::Duration) -> Audio
|
|
31
|
+
def crop: (start: Numeric | Core::Duration, duration: Numeric | Core::Duration) -> Audio
|
|
32
|
+
def concat: (Audio other) -> Audio
|
|
33
|
+
def append: (Audio other) -> Audio
|
|
34
|
+
def +: (Audio other) -> Audio
|
|
35
|
+
def prepend: (Audio other) -> Audio
|
|
36
|
+
def overlay: (Audio other, at: Numeric | Core::Duration, ?strategy: Symbol, ?headroom_smoothing: Numeric) -> Audio
|
|
37
|
+
def crossfade: (Audio other, duration: Numeric | Core::Duration) -> Audio
|
|
38
|
+
def pad_start: (Numeric seconds) -> Audio
|
|
39
|
+
def pad_end: (Numeric seconds) -> Audio
|
|
40
|
+
def insert_silence: (at: Numeric | Core::Duration, duration: Numeric | Core::Duration) -> Audio
|
|
41
|
+
def repeat: (times: Integer) -> Audio
|
|
42
|
+
def repeat_chunks: (times: Integer, ?chunk_frames: Integer) -> Enumerator[Audio, nil]
|
|
43
|
+
def repeat!: (times: Integer) -> Audio
|
|
44
|
+
def reverse: () -> Audio
|
|
45
|
+
def reverse!: () -> Audio
|
|
46
|
+
def gain: (Numeric db) -> Audio
|
|
47
|
+
def gain!: (Numeric db) -> Audio
|
|
48
|
+
def normalize: (?target_db: Numeric, ?mode: Symbol) -> Audio
|
|
49
|
+
def normalize!: (?target_db: Numeric, ?mode: Symbol) -> Audio
|
|
50
|
+
def trim: (?threshold: Numeric) -> Audio
|
|
51
|
+
def trim!: (?threshold: Numeric) -> Audio
|
|
52
|
+
def fade_in: (Numeric seconds, ?curve: Symbol) -> Audio
|
|
53
|
+
def fade_in!: (Numeric seconds, ?curve: Symbol) -> Audio
|
|
54
|
+
def fade_out: (Numeric seconds, ?curve: Symbol) -> Audio
|
|
55
|
+
def fade_out!: (Numeric seconds, ?curve: Symbol) -> Audio
|
|
56
|
+
def fade: (Numeric seconds, type: Symbol, ?curve: Symbol) -> Audio
|
|
57
|
+
def pan: (Numeric position) -> Audio
|
|
58
|
+
def pan!: (Numeric position) -> Audio
|
|
59
|
+
def balance: (Numeric position) -> Audio
|
|
60
|
+
def stereo_rotate: (Numeric position) -> Audio
|
|
61
|
+
def apply: (untyped effect) -> Audio
|
|
62
|
+
def apply!: (untyped effect) -> Audio
|
|
63
|
+
def map_samples: () { (Numeric, Integer) -> Numeric } -> Audio
|
|
64
|
+
def map_frames: () { (Array[Numeric], Integer) -> Array[Numeric] } -> Audio
|
|
65
|
+
def sample_peak_amplitude: () -> Float
|
|
66
|
+
def peak_amplitude: () -> Float
|
|
67
|
+
def true_peak_amplitude: (?oversampling: Integer) -> Float
|
|
68
|
+
def rms_amplitude: () -> Float
|
|
69
|
+
def peak_dbfs: () -> Float
|
|
70
|
+
def sample_peak_dbfs: () -> Float
|
|
71
|
+
def true_peak_dbfs: (?oversampling: Integer) -> Float
|
|
72
|
+
def rms_dbfs: () -> Float
|
|
73
|
+
def lufs: () -> Float
|
|
74
|
+
def stats: () -> Hash[Symbol, untyped]
|
|
75
|
+
def silent?: (?threshold: Numeric) -> bool
|
|
76
|
+
def clipped?: (?consecutive_frames: Integer) -> bool
|
|
77
|
+
def dc_offset: () -> Float
|
|
78
|
+
def dc_offsets: () -> Array[Float]
|
|
79
|
+
def remove_dc_offset: () -> Audio
|
|
80
|
+
def zero_crossing_rate: () -> Float
|
|
81
|
+
def ==: (untyped other) -> bool
|
|
82
|
+
def eql?: (untyped other) -> bool
|
|
83
|
+
def hash: () -> Integer
|
|
84
|
+
def inspect: () -> String
|
|
85
|
+
end
|
|
86
|
+
end
|