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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/README.md +204 -22
  4. data/exe/wavify +6 -0
  5. data/lib/wavify/adapters.rb +81 -0
  6. data/lib/wavify/audio.rb +1066 -120
  7. data/lib/wavify/cli.rb +237 -0
  8. data/lib/wavify/codecs/aiff.rb +388 -87
  9. data/lib/wavify/codecs/base.rb +98 -5
  10. data/lib/wavify/codecs/flac.rb +660 -170
  11. data/lib/wavify/codecs/ogg_vorbis.rb +515 -155
  12. data/lib/wavify/codecs/raw.rb +235 -45
  13. data/lib/wavify/codecs/registry.rb +272 -10
  14. data/lib/wavify/codecs/wav.rb +453 -74
  15. data/lib/wavify/core/duration.rb +54 -2
  16. data/lib/wavify/core/format.rb +96 -11
  17. data/lib/wavify/core/sample_buffer.rb +648 -60
  18. data/lib/wavify/core/stream.rb +590 -34
  19. data/lib/wavify/dsl.rb +712 -100
  20. data/lib/wavify/dsp/automation.rb +109 -0
  21. data/lib/wavify/dsp/effects/auto_pan.rb +71 -0
  22. data/lib/wavify/dsp/effects/bitcrusher.rb +74 -0
  23. data/lib/wavify/dsp/effects/chorus.rb +15 -4
  24. data/lib/wavify/dsp/effects/compressor.rb +133 -23
  25. data/lib/wavify/dsp/effects/delay.rb +46 -1
  26. data/lib/wavify/dsp/effects/distortion.rb +3 -2
  27. data/lib/wavify/dsp/effects/effect_base.rb +69 -2
  28. data/lib/wavify/dsp/effects/effect_chain.rb +94 -0
  29. data/lib/wavify/dsp/effects/envelope_controlled_effect.rb +93 -0
  30. data/lib/wavify/dsp/effects/eq.rb +102 -0
  31. data/lib/wavify/dsp/effects/expander.rb +90 -0
  32. data/lib/wavify/dsp/effects/flanger.rb +112 -0
  33. data/lib/wavify/dsp/effects/limiter.rb +259 -0
  34. data/lib/wavify/dsp/effects/mastering_chain.rb +31 -0
  35. data/lib/wavify/dsp/effects/noise_gate.rb +72 -0
  36. data/lib/wavify/dsp/effects/phaser.rb +112 -0
  37. data/lib/wavify/dsp/effects/podcast_chain.rb +32 -0
  38. data/lib/wavify/dsp/effects/reverb.rb +100 -14
  39. data/lib/wavify/dsp/effects/soft_limiter.rb +55 -0
  40. data/lib/wavify/dsp/effects/stereo_widener.rb +73 -0
  41. data/lib/wavify/dsp/effects/tremolo.rb +66 -0
  42. data/lib/wavify/dsp/effects/vibrato.rb +102 -0
  43. data/lib/wavify/dsp/effects.rb +106 -0
  44. data/lib/wavify/dsp/envelope.rb +90 -19
  45. data/lib/wavify/dsp/filter.rb +149 -8
  46. data/lib/wavify/dsp/headroom.rb +57 -0
  47. data/lib/wavify/dsp/lfo.rb +65 -0
  48. data/lib/wavify/dsp/loudness_meter.rb +281 -0
  49. data/lib/wavify/dsp/oscillator.rb +261 -22
  50. data/lib/wavify/dsp/processor.rb +90 -0
  51. data/lib/wavify/errors.rb +2 -2
  52. data/lib/wavify/sequencer/engine.rb +405 -89
  53. data/lib/wavify/sequencer/note_sequence.rb +45 -7
  54. data/lib/wavify/sequencer/pattern.rb +121 -12
  55. data/lib/wavify/sequencer/track.rb +149 -10
  56. data/lib/wavify/version.rb +1 -1
  57. data/lib/wavify.rb +18 -0
  58. data/sig/audio.rbs +86 -0
  59. data/sig/codecs.rbs +77 -0
  60. data/sig/core.rbs +108 -0
  61. data/sig/dsl.rbs +32 -0
  62. data/sig/dsp.rbs +87 -0
  63. data/sig/effects.rbs +129 -0
  64. data/sig/public_api.yml +111 -0
  65. data/sig/sequencer.rbs +126 -0
  66. data/sig/stream.rbs +30 -0
  67. data/sig/wavify.rbs +24 -0
  68. metadata +44 -58
  69. data/.serena/.gitignore +0 -1
  70. data/.serena/memories/project_overview.md +0 -5
  71. data/.serena/memories/style_and_completion.md +0 -5
  72. data/.serena/memories/suggested_commands.md +0 -11
  73. data/.serena/project.yml +0 -126
  74. data/.simplecov +0 -18
  75. data/.yardopts +0 -4
  76. data/Rakefile +0 -190
  77. data/benchmarks/README.md +0 -46
  78. data/benchmarks/benchmark_helper.rb +0 -112
  79. data/benchmarks/dsp_effects_benchmark.rb +0 -46
  80. data/benchmarks/flac_benchmark.rb +0 -74
  81. data/benchmarks/streaming_memory_benchmark.rb +0 -94
  82. data/benchmarks/wav_io_benchmark.rb +0 -110
  83. data/examples/audio_processing.rb +0 -73
  84. data/examples/cinematic_transition.rb +0 -118
  85. data/examples/drum_machine.rb +0 -74
  86. data/examples/format_convert.rb +0 -81
  87. data/examples/hybrid_arrangement.rb +0 -165
  88. data/examples/streaming_master_chain.rb +0 -129
  89. data/examples/synth_pad.rb +0 -42
  90. 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
- @notation = notation
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
- Event.new(index: index, token: token, midi_note: parse_token(token))
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
- return nil if token == "."
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
- return parse_midi_number(token) if token.match?(/\A-?\d+\z/)
133
+ value = Integer(match[1])
134
+ raise InvalidNoteError, "duration denominator must be positive: #{token.inspect}" unless value.positive?
104
135
 
105
- parse_note_name(token)
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
- @notation = notation
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
- raise InvalidPatternError, "resolution must be a positive Integer" unless value.is_a?(Integer) && value.positive?
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
- chars.each_with_index.map do |char, index|
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
- Step.new(index: index, trigger: true, accent: false, symbol: char, velocity: 0.8)
91
- when "X"
92
- Step.new(index: index, trigger: true, accent: true, symbol: char, velocity: 1.0)
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(index: index, trigger: false, accent: false, symbol: char, velocity: 0.0)
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, 3, 7, 10],
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
- match = token.match(/\A([A-Ga-g])([#b]?)(.*)\z/)
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: intervals.map { |interval| root_midi + interval }
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
- raise SequencerError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
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
- raise SequencerError, "#{name} must be Numeric" unless value.is_a?(Numeric)
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
- raise SequencerError, "pan_position must be Numeric in -1.0..1.0" unless value.is_a?(Numeric) && value.between?(-1.0, 1.0)
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Wavify
4
4
  # Gem version string (Semantic Versioning).
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
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