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
@@ -6,41 +6,88 @@ module Wavify
6
6
  class Engine
7
7
  # Default beats-per-bar value used when omitted.
8
8
  DEFAULT_BEATS_PER_BAR = 4
9
-
10
- attr_reader :tempo, :format, :beats_per_bar
11
-
12
- def initialize(tempo:, format: Wavify::Core::Format::CD_QUALITY, beats_per_bar: DEFAULT_BEATS_PER_BAR)
9
+ # Maximum bars accepted by a single render request.
10
+ MAX_BARS = 100_000
11
+ # Maximum structural sections accepted before repeat expansion.
12
+ MAX_ARRANGEMENT_SECTIONS = 10_000
13
+ # Maximum sections produced by lazy arrangement repeat expansion.
14
+ MAX_EXPANDED_SECTIONS = 100_000
15
+ # Maximum repeat count on one arrangement section.
16
+ MAX_SECTION_REPEAT = 10_000
17
+ # Maximum scheduled timeline events in one build.
18
+ MAX_EVENTS = 1_000_000
19
+ # Final sequencer ceiling leaves conversion margin below digital full scale.
20
+ MASTER_CEILING_DB = -0.1
21
+ # The master limiter uses its full lookahead window to ramp gain before peaks.
22
+ MASTER_LOOKAHEAD_SECONDS = 0.005
23
+
24
+ attr_reader :tempo, :format, :beats_per_bar, :swing
25
+
26
+ def initialize(tempo:, format: Wavify::Core::Format::CD_QUALITY, beats_per_bar: DEFAULT_BEATS_PER_BAR, swing: 0.5)
13
27
  @tempo = validate_tempo!(tempo)
14
28
  @format = validate_format!(format)
15
29
  @beats_per_bar = validate_beats_per_bar!(beats_per_bar)
30
+ @swing = validate_swing!(swing)
31
+ @section_engine_cache = {}
16
32
  end
17
33
 
18
34
  # @return [Float] seconds per beat at the current tempo
19
35
  def seconds_per_beat
20
- 60.0 / @tempo
36
+ seconds_per_beat_rational.to_f
21
37
  end
22
38
 
23
39
  # @return [Float] duration of one bar in seconds
24
40
  def bar_duration_seconds
25
- seconds_per_beat * @beats_per_bar
41
+ bar_duration_time.to_f
26
42
  end
27
43
 
28
44
  def step_duration_seconds(resolution)
29
45
  raise SequencerError, "resolution must be a positive Integer" unless resolution.is_a?(Integer) && resolution.positive?
30
46
 
31
- bar_duration_seconds / resolution.to_f
47
+ step_duration_time(resolution).to_f
48
+ end
49
+
50
+ def step_start_seconds(index, resolution)
51
+ step_start_time(index, resolution).to_f
52
+ end
53
+
54
+ def step_duration_at(index, resolution)
55
+ step_duration_time_at(index, resolution).to_f
32
56
  end
33
57
 
34
- def timeline_for_track(track, bars:, start_bar: 0)
58
+ def expand_pattern_step(step, start_time:, duration:)
59
+ ratchet = step.ratchet || 1
60
+ event_duration = duration / ratchet
61
+ Array.new(ratchet) do |ratchet_index|
62
+ {
63
+ start_time: start_time + (event_duration * ratchet_index),
64
+ duration: event_duration,
65
+ velocity: step.velocity,
66
+ probability: step.probability || 1.0,
67
+ ratchet_index: ratchet_index,
68
+ ratchet_count: ratchet
69
+ }
70
+ end
71
+ end
72
+
73
+ def timeline_for_track(track, bars:, start_bar: 0, start_time: nil)
35
74
  raise SequencerError, "track must be a Sequencer::Track" unless track.is_a?(Track)
36
- raise SequencerError, "bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
75
+ unless bars.is_a?(Integer) && bars.between?(1, MAX_BARS)
76
+ raise SequencerError, "bars must be an Integer in 1..#{MAX_BARS}"
77
+ end
37
78
  raise SequencerError, "start_bar must be a non-negative Integer" unless start_bar.is_a?(Integer) && start_bar >= 0
79
+ if start_time && !(start_time.is_a?(Numeric) && start_time.respond_to?(:finite?) && start_time.finite? && start_time >= 0)
80
+ raise SequencerError, "start_time must be a non-negative finite Numeric"
81
+ end
38
82
 
39
83
  events = []
40
- events.concat(schedule_pattern_events(track, bars: bars, start_bar: start_bar)) if track.pattern?
41
- events.concat(schedule_note_events(track, bars: bars, start_bar: start_bar)) if track.notes?
42
- events.concat(schedule_chord_events(track, bars: bars, start_bar: start_bar)) if track.chords?
43
- events.sort_by { |event| [event[:start_time], event[:kind].to_s] }
84
+ events.concat(schedule_pattern_events(track, bars: bars, start_bar: start_bar, start_time: start_time)) if track.pattern?
85
+ events.concat(schedule_note_events(track, bars: bars, start_bar: start_bar, start_time: start_time)) if track.notes?
86
+ events.concat(schedule_chord_events(track, bars: bars, start_bar: start_bar, start_time: start_time)) if track.chords?
87
+ raise SequencerError, "timeline exceeds #{MAX_EVENTS} events" if events.length > MAX_EVENTS
88
+
89
+ events.map { |event| decorate_event_timing(event) }
90
+ .sort_by { |event| [event[:start_frame], event[:kind].to_s] }
44
91
  end
45
92
 
46
93
  # Builds a combined event timeline for tracks and optional arrangement.
@@ -55,7 +102,9 @@ module Wavify
55
102
  if arrangement
56
103
  build_arranged_timeline(track_map, arrangement)
57
104
  else
58
- raise SequencerError, "default_bars must be a positive Integer" unless default_bars.is_a?(Integer) && default_bars.positive?
105
+ unless default_bars.is_a?(Integer) && default_bars.between?(1, MAX_BARS)
106
+ raise SequencerError, "default_bars must be an Integer in 1..#{MAX_BARS}"
107
+ end
59
108
 
60
109
  events = track_map.values.flat_map do |track|
61
110
  timeline_for_track(track, bars: default_bars)
@@ -82,7 +131,7 @@ module Wavify
82
131
 
83
132
  return Wavify::Audio.silence(0.0, format: @format) if rendered_audios.empty?
84
133
 
85
- Wavify::Audio.mix(*rendered_audios)
134
+ master_audio(rendered_audios)
86
135
  end
87
136
 
88
137
  private
@@ -93,6 +142,7 @@ module Wavify
93
142
 
94
143
  list.each_with_object({}) do |track, map|
95
144
  raise SequencerError, "track must be a Sequencer::Track" unless track.is_a?(Track)
145
+ raise SequencerError, "duplicate track name: #{track.name}" if map.key?(track.name)
96
146
 
97
147
  map[track.name] = track
98
148
  end
@@ -100,135 +150,291 @@ module Wavify
100
150
 
101
151
  def normalize_arrangement(arrangement, track_map)
102
152
  raise SequencerError, "arrangement must be an Array" unless arrangement.is_a?(Array)
153
+ if arrangement.length > MAX_ARRANGEMENT_SECTIONS
154
+ raise SequencerError, "arrangement exceeds #{MAX_ARRANGEMENT_SECTIONS} sections"
155
+ end
156
+
157
+ normalized_sections = arrangement.map { |section| normalize_arrangement_section(section, track_map) }
158
+ expanded_count = normalized_sections.sum { |section| section.fetch(:repeat) }
159
+ if expanded_count > MAX_EXPANDED_SECTIONS
160
+ raise SequencerError, "expanded arrangement exceeds #{MAX_EXPANDED_SECTIONS} sections"
161
+ end
162
+
163
+ Enumerator.new do |yielder|
164
+ cursor_bar = 0
165
+ cursor_time = Rational(0, 1)
166
+ normalized_sections.each do |section|
167
+ name = section[:name] || :"section_#{cursor_bar}"
168
+ section_engine = engine_for_section(section)
169
+ section.fetch(:repeat).times do |repeat_index|
170
+ yielder << {
171
+ name: repeated_section_name(name, repeat_index),
172
+ bars: section.fetch(:bars),
173
+ tracks: section.fetch(:tracks),
174
+ start_bar: cursor_bar,
175
+ start_time: cursor_time,
176
+ tempo: section.fetch(:tempo),
177
+ beats_per_bar: section.fetch(:beats_per_bar),
178
+ markers: section.fetch(:markers)
179
+ }
180
+ cursor_bar += section.fetch(:bars)
181
+ cursor_time += section.fetch(:bars) * section_engine.send(:bar_duration_time)
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ def normalize_arrangement_section(section, track_map)
188
+ raise SequencerError, "section must be a Hash" unless section.is_a?(Hash)
103
189
 
104
- cursor_bar = 0
105
- arrangement.map do |section|
106
- raise SequencerError, "section must be a Hash" unless section.is_a?(Hash)
190
+ bars = section[:bars]
191
+ unless bars.is_a?(Integer) && bars.between?(1, MAX_BARS)
192
+ raise SequencerError, "section bars must be an Integer in 1..#{MAX_BARS}"
193
+ end
194
+ repeat = section.fetch(:repeat, 1)
195
+ unless repeat.is_a?(Integer) && repeat.between?(1, MAX_SECTION_REPEAT)
196
+ raise SequencerError, "section repeat must be an Integer in 1..#{MAX_SECTION_REPEAT}"
197
+ end
107
198
 
108
- name = section.fetch(:name, "section_#{cursor_bar}").to_sym
109
- bars = section.fetch(:bars)
110
- raise SequencerError, "section bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
199
+ name = normalize_section_name(section[:name]) if section.key?(:name)
200
+ track_names = normalize_section_track_names(section[:tracks])
201
+ unknown = track_names - track_map.keys
202
+ section_label = name || :unnamed
203
+ raise SequencerError, "unknown tracks in section #{section_label}: #{unknown.join(', ')}" unless unknown.empty?
204
+
205
+ {
206
+ name: name,
207
+ bars: bars,
208
+ repeat: repeat,
209
+ tracks: track_names,
210
+ tempo: normalize_section_tempo(section[:tempo] || @tempo),
211
+ beats_per_bar: validate_beats_per_bar!(section[:beats_per_bar] || @beats_per_bar),
212
+ markers: normalize_section_markers(section.fetch(:markers, []))
213
+ }
214
+ end
111
215
 
112
- track_names = Array(section.fetch(:tracks)).map(&:to_sym)
113
- raise SequencerError, "section tracks must not be empty" if track_names.empty?
216
+ def normalize_section_name(name)
217
+ value = name.to_sym
218
+ raise SequencerError, "section name must not be empty" if value.to_s.empty?
114
219
 
115
- unknown = track_names - track_map.keys
116
- raise SequencerError, "unknown tracks in section #{name}: #{unknown.join(', ')}" unless unknown.empty?
220
+ value
221
+ rescue NoMethodError
222
+ raise SequencerError, "section name must be Symbol/String"
223
+ end
117
224
 
118
- normalized = { name: name, bars: bars, tracks: track_names, start_bar: cursor_bar }
119
- cursor_bar += bars
120
- normalized
225
+ def normalize_section_track_names(tracks)
226
+ Array(tracks).map do |track_name|
227
+ track_name.to_sym
228
+ rescue NoMethodError
229
+ raise SequencerError, "section tracks must be Symbols/Strings"
121
230
  end
122
231
  end
123
232
 
124
233
  def build_arranged_timeline(track_map, arrangement)
125
234
  sections = normalize_arrangement(arrangement, track_map)
126
235
  events = sections.flat_map do |section|
127
- section[:tracks].flat_map do |track_name|
128
- timeline_for_track(track_map.fetch(track_name), bars: section[:bars], start_bar: section[:start_bar])
236
+ section_engine = engine_for_section(section)
237
+ section_events = section[:tracks].flat_map do |track_name|
238
+ section_engine.timeline_for_track(
239
+ track_map.fetch(track_name),
240
+ bars: section[:bars],
241
+ start_bar: section[:start_bar],
242
+ start_time: section[:start_time]
243
+ )
129
244
  end
245
+ section_events + marker_events_for_section(section)
130
246
  end
131
247
  events.sort_by { |event| [event[:start_time], event[:track].to_s] }
132
248
  end
133
249
 
134
250
  def render_arranged_tracks(track_map, sections)
135
- sections.flat_map do |section|
136
- section[:tracks].filter_map do |track_name|
137
- render_track_audio(track_map.fetch(track_name), bars: section[:bars], start_bar: section[:start_bar])
251
+ work_format = @format.with(sample_format: :float, bit_depth: 32)
252
+ samples = []
253
+ sections.each do |section|
254
+ section_engine = engine_for_section(section)
255
+ section[:tracks].each do |track_name|
256
+ audio = section_engine.send(
257
+ :render_track_audio,
258
+ track_map.fetch(track_name),
259
+ bars: section[:bars],
260
+ start_bar: 0,
261
+ start_time: 0.0
262
+ )
263
+ next unless audio
264
+
265
+ source = audio.buffer.convert(work_format)
266
+ offset = (section[:start_time] * @format.sample_rate).round * work_format.channels
267
+ required_length = offset + source.samples.length
268
+ samples.concat(Array.new(required_length - samples.length, 0.0)) if required_length > samples.length
269
+ source.samples.each_with_index { |sample, index| samples[offset + index] += sample }
138
270
  end
139
271
  end
272
+ return [] if samples.empty?
273
+
274
+ [Wavify::Audio.new(Wavify::Core::SampleBuffer.new(samples, work_format))]
140
275
  end
141
276
 
142
- def render_track_audio(track, bars:, start_bar: 0)
143
- events = timeline_for_track(track, bars: bars, start_bar: start_bar)
277
+ def render_track_audio(track, bars:, start_bar: 0, start_time: nil)
278
+ events = timeline_for_track(track, bars: bars, start_bar: start_bar, start_time: start_time)
144
279
  note_events = events.select { |event| %i[note chord].include?(event[:kind]) }
280
+ if note_events.empty? && events.any? { |event| event[:kind] == :trigger }
281
+ raise SequencerError, "trigger patterns require a sample-backed DSL track; synth tracks must use notes or chords"
282
+ end
145
283
  return nil if note_events.empty?
146
284
 
147
285
  track_format = @format.channels == 2 ? @format : @format.with(channels: 2)
148
- total_end_time = note_events.map { |event| event[:start_time] + event[:duration] }.max || 0.0
149
- audio = Wavify::Audio.silence(total_end_time, format: track_format.with(sample_format: :float, bit_depth: 32))
150
- mixed = audio.buffer.samples.dup
286
+ release_seconds = track.envelope&.release || 0.0
287
+ release_frames = (release_seconds * track_format.sample_rate).ceil
288
+ total_end_frame = note_events.map do |event|
289
+ event.fetch(:start_frame) + event.fetch(:duration_frames) + release_frames
290
+ end.max || 0
291
+ work_format = track_format.with(sample_format: :float, bit_depth: 32)
292
+ mixed = Array.new(total_end_frame * work_format.channels, 0.0)
151
293
 
152
294
  note_events.each do |event|
153
295
  frequencies = event[:midi_notes].map { |midi| midi_to_frequency(midi) }
154
- note_audio = render_chord_tone(frequencies, event[:duration], track, track_format.with(sample_format: :float, bit_depth: 32))
155
- start_frame = (event[:start_time] * track_format.sample_rate).round
156
- start_index = start_frame * track_format.channels
157
-
158
- note_audio.buffer.samples.each_with_index do |sample, index|
159
- target_index = start_index + index
160
- break if target_index >= mixed.length
161
-
162
- mixed[target_index] += sample
163
- end
296
+ render_oscillator_voices_into!(
297
+ mixed,
298
+ frequencies: frequencies,
299
+ start_frame: event.fetch(:start_frame),
300
+ duration: event.fetch(:duration),
301
+ track: track,
302
+ format: work_format
303
+ )
164
304
  end
165
305
 
166
- mixed.map! { |sample| sample.clamp(-1.0, 1.0) }
167
- rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, track_format.with(sample_format: :float, bit_depth: 32)))
306
+ rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, work_format))
168
307
  rendered = rendered.gain(track.gain_db) if track.gain_db != 0.0
169
- rendered = rendered.pan(track.pan_position) if track.pan_position != 0.0
308
+ if track.pan_position != 0.0
309
+ rendered = rendered.channels == 1 ? rendered.pan(track.pan_position) : rendered.balance(track.pan_position)
310
+ end
170
311
  rendered = apply_track_effects(rendered, track.effects) if track.effects?
171
- rendered.convert(@format)
172
- end
173
-
174
- def render_chord_tone(frequencies, duration, track, format)
175
- note_audios = frequencies.map do |frequency|
176
- tone = Wavify::Audio.tone(frequency: frequency, duration: duration, format: format, waveform: track.waveform)
177
- if track.envelope
178
- tone.apply(lambda do |buffer|
179
- track.envelope.apply(buffer, note_on_duration: duration)
180
- end)
181
- else
182
- tone
312
+ rendered.convert(@format.with(sample_format: :float, bit_depth: 32))
313
+ end
314
+
315
+ def render_oscillator_voices_into!(samples, frequencies:, start_frame:, duration:, track:, format:)
316
+ rendered_duration = duration.to_f + (track.envelope&.release || 0.0)
317
+ frame_count = (rendered_duration * format.sample_rate).round
318
+ if rendered_duration > Wavify::DSP::Oscillator::MAX_DURATION_SECONDS ||
319
+ frame_count > Wavify::DSP::Oscillator::MAX_GENERATE_FRAMES
320
+ raise SequencerError, "one sequencer note exceeds the oscillator frame limit"
321
+ end
322
+
323
+ mono_format = format.with(channels: 1)
324
+ voice_gain = 1.0 / frequencies.length
325
+ channels = format.channels
326
+ target_frame_limit = samples.length / channels
327
+ mixed_frame_count = [frame_count, target_frame_limit - start_frame].min
328
+ return if mixed_frame_count <= 0
329
+
330
+ envelope_gains = if track.envelope
331
+ Array.new(mixed_frame_count) do |frame_index|
332
+ time = frame_index.to_f / format.sample_rate
333
+ track.envelope.gain_at(time, note_on_duration: duration)
334
+ end
335
+ end
336
+ frequencies.each do |frequency|
337
+ voice = Wavify::DSP::Oscillator.new(
338
+ frequency: frequency,
339
+ waveform: track.waveform,
340
+ **track.synth_options
341
+ ).generate(rendered_duration, format: mono_format)
342
+ frame_index = 0
343
+ while frame_index < mixed_frame_count
344
+ target_frame = start_frame + frame_index
345
+ value = voice.samples.fetch(frame_index) * voice_gain
346
+ value *= envelope_gains.fetch(frame_index) if envelope_gains
347
+ base_index = target_frame * channels
348
+ channels.times { |channel| samples[base_index + channel] += value }
349
+ frame_index += 1
183
350
  end
184
351
  end
352
+ end
185
353
 
186
- Wavify::Audio.mix(*note_audios)
354
+ def master_audio(rendered_audios)
355
+ work_format = @format.with(sample_format: :float, bit_depth: 32)
356
+ mixed = Wavify::Audio.mix(*rendered_audios, strategy: :none, format: work_format)
357
+ return Wavify::Audio.new(mixed.buffer.convert(@format)) if mixed.peak_amplitude <= 1.0
358
+
359
+ limiter = Wavify::DSP::Effects::Limiter.new(
360
+ ceiling: MASTER_CEILING_DB,
361
+ attack: MASTER_LOOKAHEAD_SECONDS,
362
+ lookahead: MASTER_LOOKAHEAD_SECONDS
363
+ )
364
+ limited = limiter.apply(mixed.buffer)
365
+ Wavify::Audio.new(limited.convert(@format))
187
366
  end
188
367
 
189
- def schedule_pattern_events(track, bars:, start_bar:)
190
- step_duration = step_duration_seconds(track.pattern.length)
368
+ def schedule_pattern_events(track, bars:, start_bar:, start_time:)
369
+ resolution = track.pattern.resolution
191
370
 
192
371
  (0...bars).flat_map do |bar_offset|
193
- track.pattern.filter(&:trigger?).map do |step|
372
+ track.pattern.filter(&:trigger?).flat_map do |step|
194
373
  absolute_bar = start_bar + bar_offset
195
- start_time = (absolute_bar * bar_duration_seconds) + (step.index * step_duration)
196
- {
197
- kind: :trigger,
198
- track: track.name,
199
- bar: absolute_bar,
200
- step_index: step.index,
201
- start_time: start_time,
202
- duration: step_duration,
203
- velocity: step.velocity
204
- }
374
+ event_start_time = bar_start_time(absolute_bar, bar_offset, start_time) + step_start_time(step.index, resolution)
375
+ duration = step_duration_time_at(step.index, resolution)
376
+ expand_pattern_step(step, start_time: event_start_time, duration: duration).map do |event|
377
+ event.merge(kind: :trigger, track: track.name, bar: absolute_bar, step_index: step.index)
378
+ end
205
379
  end
206
380
  end
207
381
  end
208
382
 
209
- def schedule_note_events(track, bars:, start_bar:)
210
- base_step_duration = step_duration_seconds(track.note_resolution)
383
+ def schedule_note_events(track, bars:, start_bar:, start_time:)
384
+ resolution = track.note_resolution
211
385
 
212
386
  (0...bars).flat_map do |bar_offset|
213
- track.note_sequence.each_with_object([]) do |event, result|
387
+ result = []
388
+ events = track.note_sequence.events
389
+ index = 0
390
+ while index < events.length
391
+ event = events.fetch(index)
392
+ index += 1
214
393
  next if event.rest?
215
394
 
216
395
  absolute_bar = start_bar + bar_offset
217
- start_time = (absolute_bar * bar_duration_seconds) + (event.index * base_step_duration)
396
+ event_start_time = bar_start_time(absolute_bar, bar_offset, start_time) + step_start_time(event.index, resolution)
397
+ duration, index = note_duration_and_next_index(events, index - 1, resolution)
218
398
  result << {
219
399
  kind: :note,
220
400
  track: track.name,
221
401
  bar: absolute_bar,
222
402
  step_index: event.index,
223
- start_time: start_time,
224
- duration: base_step_duration,
225
- midi_notes: [event.midi_note]
403
+ start_time: event_start_time,
404
+ duration: duration,
405
+ midi_notes: [track.quantize_midi_note(event.midi_note)]
226
406
  }
227
407
  end
408
+ result
409
+ end
410
+ end
411
+
412
+ def note_duration_and_next_index(events, start_index, resolution)
413
+ event = events.fetch(start_index)
414
+ duration = note_event_duration(event, resolution)
415
+ cursor = start_index
416
+
417
+ while event.tie? && cursor + 1 < events.length
418
+ next_event = events.fetch(cursor + 1)
419
+ break if next_event.rest? || next_event.midi_note != event.midi_note
420
+
421
+ duration += note_event_duration(next_event, resolution)
422
+ cursor += 1
423
+ event = next_event
228
424
  end
425
+
426
+ [duration, cursor + 1]
427
+ end
428
+
429
+ def note_event_duration(event, resolution)
430
+ if event.duration_denominator
431
+ return (bar_duration_time / event.duration_denominator) * Rational(event.duration_multiplier.to_s)
432
+ end
433
+
434
+ step_duration_time_at(event.index, resolution)
229
435
  end
230
436
 
231
- def schedule_chord_events(track, bars:, start_bar:)
437
+ def schedule_chord_events(track, bars:, start_bar:, start_time:)
232
438
  progression = track.chord_progression
233
439
  return [] unless progression
234
440
 
@@ -240,8 +446,8 @@ module Wavify
240
446
  track: track.name,
241
447
  bar: absolute_bar,
242
448
  step_index: 0,
243
- start_time: absolute_bar * bar_duration_seconds,
244
- duration: bar_duration_seconds,
449
+ start_time: bar_start_time(absolute_bar, bar_offset, start_time),
450
+ duration: bar_duration_time,
245
451
  midi_notes: chord.fetch(:midi_notes),
246
452
  chord: chord.fetch(:token)
247
453
  }
@@ -253,11 +459,13 @@ module Wavify
253
459
  end
254
460
 
255
461
  def apply_track_effects(audio, effects)
256
- effects.reduce(audio) { |current, effect| current.apply(effect) }
462
+ audio.apply(Wavify::DSP::Effects::EffectChain.new(effects))
257
463
  end
258
464
 
259
465
  def validate_tempo!(tempo)
260
- raise SequencerError, "tempo must be a positive Numeric" unless tempo.is_a?(Numeric) && tempo.positive?
466
+ unless tempo.is_a?(Numeric) && tempo.respond_to?(:finite?) && tempo.finite? && tempo.positive?
467
+ raise SequencerError, "tempo must be a positive finite Numeric"
468
+ end
261
469
 
262
470
  tempo.to_f
263
471
  end
@@ -273,6 +481,114 @@ module Wavify
273
481
 
274
482
  beats_per_bar
275
483
  end
484
+
485
+ def validate_swing!(swing)
486
+ unless swing.is_a?(Numeric) && swing.finite? && swing >= 0.5 && swing < 1.0
487
+ raise SequencerError, "swing must be a Numeric between 0.5 and 1.0"
488
+ end
489
+
490
+ swing.to_f
491
+ end
492
+
493
+ def straight_timing?(resolution)
494
+ (@swing - 0.5).abs <= Float::EPSILON || resolution.odd?
495
+ end
496
+
497
+ def repeated_section_name(name, repeat_index)
498
+ return name if repeat_index.zero?
499
+
500
+ :"#{name}_#{repeat_index + 1}"
501
+ end
502
+
503
+ def seconds_per_beat_rational
504
+ Rational(60, 1) / Rational(@tempo.to_s)
505
+ end
506
+
507
+ def bar_duration_time
508
+ seconds_per_beat_rational * @beats_per_bar
509
+ end
510
+
511
+ def step_duration_time(resolution)
512
+ raise SequencerError, "resolution must be a positive Integer" unless resolution.is_a?(Integer) && resolution.positive?
513
+
514
+ bar_duration_time / resolution
515
+ end
516
+
517
+ def step_start_time(index, resolution)
518
+ base_duration = step_duration_time(resolution)
519
+ return index * base_duration if straight_timing?(resolution)
520
+
521
+ pair_duration = base_duration * 2
522
+ ((index / 2) * pair_duration) + (index.even? ? 0 : pair_duration * Rational(@swing.to_s))
523
+ end
524
+
525
+ def step_duration_time_at(index, resolution)
526
+ base_duration = step_duration_time(resolution)
527
+ return base_duration if straight_timing?(resolution)
528
+
529
+ pair_duration = base_duration * 2
530
+ pair_duration * (index.even? ? Rational(@swing.to_s) : (1 - Rational(@swing.to_s)))
531
+ end
532
+
533
+ def bar_start_time(absolute_bar, bar_offset, start_time)
534
+ return (Rational(start_time.to_s) + (bar_offset * bar_duration_time)) if start_time
535
+
536
+ absolute_bar * bar_duration_time
537
+ end
538
+
539
+ def engine_for_section(section)
540
+ tempo = section[:tempo] || @tempo
541
+ beats_per_bar = section[:beats_per_bar] || @beats_per_bar
542
+ return self if tempo == @tempo && beats_per_bar == @beats_per_bar
543
+
544
+ @section_engine_cache[[tempo, beats_per_bar]] ||= self.class.new(
545
+ tempo: tempo,
546
+ format: @format,
547
+ beats_per_bar: beats_per_bar,
548
+ swing: @swing
549
+ )
550
+ end
551
+
552
+ def marker_events_for_section(section)
553
+ section.fetch(:markers, []).map do |marker|
554
+ decorate_event_timing({
555
+ kind: :marker,
556
+ track: :arrangement,
557
+ bar: section.fetch(:start_bar),
558
+ step_index: 0,
559
+ start_time: section.fetch(:start_time),
560
+ duration: 0.0,
561
+ marker: marker,
562
+ section: section.fetch(:name)
563
+ })
564
+ end
565
+ end
566
+
567
+ def decorate_event_timing(event)
568
+ start_time = event.fetch(:start_time)
569
+ duration = event.fetch(:duration)
570
+ event.merge(
571
+ start_time: start_time.to_f,
572
+ duration: duration.to_f,
573
+ start_frame: (start_time * @format.sample_rate).round,
574
+ duration_frames: (duration * @format.sample_rate).round
575
+ )
576
+ end
577
+
578
+ def normalize_section_tempo(value)
579
+ validate_tempo!(value)
580
+ end
581
+
582
+ def normalize_section_markers(markers)
583
+ Array(markers).map do |marker|
584
+ value = marker.to_sym
585
+ raise SequencerError, "section markers must not be empty" if value.to_s.empty?
586
+
587
+ value
588
+ rescue NoMethodError
589
+ raise SequencerError, "section markers must be Symbol/String"
590
+ end
591
+ end
276
592
  end
277
593
  end
278
594
  end