wavify 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.serena/.gitignore +1 -0
  3. data/.serena/memories/project_overview.md +5 -0
  4. data/.serena/memories/style_and_completion.md +5 -0
  5. data/.serena/memories/suggested_commands.md +11 -0
  6. data/.serena/project.yml +126 -0
  7. data/.simplecov +18 -0
  8. data/.yardopts +4 -0
  9. data/CHANGELOG.md +11 -0
  10. data/LICENSE +21 -0
  11. data/README.md +196 -0
  12. data/Rakefile +190 -0
  13. data/benchmarks/README.md +46 -0
  14. data/benchmarks/benchmark_helper.rb +112 -0
  15. data/benchmarks/dsp_effects_benchmark.rb +46 -0
  16. data/benchmarks/flac_benchmark.rb +74 -0
  17. data/benchmarks/streaming_memory_benchmark.rb +94 -0
  18. data/benchmarks/wav_io_benchmark.rb +110 -0
  19. data/examples/audio_processing.rb +73 -0
  20. data/examples/cinematic_transition.rb +118 -0
  21. data/examples/drum_machine.rb +74 -0
  22. data/examples/format_convert.rb +81 -0
  23. data/examples/hybrid_arrangement.rb +165 -0
  24. data/examples/streaming_master_chain.rb +129 -0
  25. data/examples/synth_pad.rb +42 -0
  26. data/lib/wavify/audio.rb +483 -0
  27. data/lib/wavify/codecs/aiff.rb +338 -0
  28. data/lib/wavify/codecs/base.rb +108 -0
  29. data/lib/wavify/codecs/flac.rb +1322 -0
  30. data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
  31. data/lib/wavify/codecs/raw.rb +193 -0
  32. data/lib/wavify/codecs/registry.rb +87 -0
  33. data/lib/wavify/codecs/wav.rb +459 -0
  34. data/lib/wavify/core/duration.rb +99 -0
  35. data/lib/wavify/core/format.rb +133 -0
  36. data/lib/wavify/core/sample_buffer.rb +216 -0
  37. data/lib/wavify/core/stream.rb +129 -0
  38. data/lib/wavify/dsl.rb +537 -0
  39. data/lib/wavify/dsp/effects/chorus.rb +98 -0
  40. data/lib/wavify/dsp/effects/compressor.rb +85 -0
  41. data/lib/wavify/dsp/effects/delay.rb +69 -0
  42. data/lib/wavify/dsp/effects/distortion.rb +64 -0
  43. data/lib/wavify/dsp/effects/effect_base.rb +68 -0
  44. data/lib/wavify/dsp/effects/reverb.rb +112 -0
  45. data/lib/wavify/dsp/effects.rb +21 -0
  46. data/lib/wavify/dsp/envelope.rb +97 -0
  47. data/lib/wavify/dsp/filter.rb +271 -0
  48. data/lib/wavify/dsp/oscillator.rb +123 -0
  49. data/lib/wavify/errors.rb +34 -0
  50. data/lib/wavify/sequencer/engine.rb +278 -0
  51. data/lib/wavify/sequencer/note_sequence.rb +132 -0
  52. data/lib/wavify/sequencer/pattern.rb +102 -0
  53. data/lib/wavify/sequencer/track.rb +298 -0
  54. data/lib/wavify/sequencer.rb +12 -0
  55. data/lib/wavify/version.rb +6 -0
  56. data/lib/wavify.rb +28 -0
  57. data/tools/fixture_writer.rb +85 -0
  58. metadata +129 -0
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Digital signal processing primitives.
5
+ module DSP
6
+ # Stateful biquad filter with common factory constructors.
7
+ class Filter
8
+ attr_reader :type, :cutoff, :q, :gain_db
9
+
10
+ # Builds a low-pass filter.
11
+ #
12
+ # @return [Filter]
13
+ def self.lowpass(cutoff:, q: 0.707)
14
+ new(:lowpass, cutoff: cutoff, q: q)
15
+ end
16
+
17
+ # Builds a high-pass filter.
18
+ #
19
+ # @return [Filter]
20
+ def self.highpass(cutoff:, q: 0.707)
21
+ new(:highpass, cutoff: cutoff, q: q)
22
+ end
23
+
24
+ def self.bandpass(center:, bandwidth:)
25
+ raise InvalidParameterError, "bandwidth must be positive" unless bandwidth.is_a?(Numeric) && bandwidth.positive?
26
+
27
+ new(:bandpass, cutoff: center, q: center.to_f / bandwidth)
28
+ end
29
+
30
+ # Builds a notch filter.
31
+ #
32
+ # @return [Filter]
33
+ def self.notch(cutoff:, q: 0.707)
34
+ new(:notch, cutoff: cutoff, q: q)
35
+ end
36
+
37
+ # Builds a peaking EQ filter.
38
+ #
39
+ # @return [Filter]
40
+ def self.peaking(cutoff:, q: 1.0, gain_db: 0.0)
41
+ new(:peaking, cutoff: cutoff, q: q, gain_db: gain_db)
42
+ end
43
+
44
+ # Builds a low-shelf EQ filter.
45
+ #
46
+ # @return [Filter]
47
+ def self.lowshelf(cutoff:, gain_db:)
48
+ new(:lowshelf, cutoff: cutoff, gain_db: gain_db)
49
+ end
50
+
51
+ # Builds a high-shelf EQ filter.
52
+ #
53
+ # @return [Filter]
54
+ def self.highshelf(cutoff:, gain_db:)
55
+ new(:highshelf, cutoff: cutoff, gain_db: gain_db)
56
+ end
57
+
58
+ def initialize(type, cutoff:, q: 0.707, gain_db: 0.0)
59
+ @type = validate_type!(type)
60
+ @cutoff = validate_cutoff!(cutoff)
61
+ @q = validate_q!(q)
62
+ @gain_db = validate_gain!(gain_db)
63
+
64
+ @coefficients = nil
65
+ @coeff_sample_rate = nil
66
+ @channel_states = []
67
+ end
68
+
69
+ def apply(buffer)
70
+ raise InvalidParameterError, "buffer must be Core::SampleBuffer" unless buffer.is_a?(Core::SampleBuffer)
71
+
72
+ float_format = buffer.format.with(sample_format: :float, bit_depth: 32)
73
+ float_buffer = buffer.convert(float_format)
74
+ processed = process_interleaved(
75
+ float_buffer.samples,
76
+ sample_rate: float_format.sample_rate,
77
+ channels: float_format.channels
78
+ )
79
+
80
+ Core::SampleBuffer.new(processed, float_format).convert(buffer.format)
81
+ end
82
+
83
+ def process_sample(sample, sample_rate:, channel: 0)
84
+ raise InvalidParameterError, "sample must be Numeric" unless sample.is_a?(Numeric)
85
+ raise InvalidParameterError, "channel must be a non-negative Integer" unless channel.is_a?(Integer) && channel >= 0
86
+ raise InvalidParameterError, "sample_rate must be a positive Integer" unless sample_rate.is_a?(Integer) && sample_rate.positive?
87
+
88
+ update_coefficients!(sample_rate)
89
+ ensure_channel_states!(channel + 1)
90
+ state = @channel_states[channel]
91
+ x = sample.to_f
92
+ y = compute_biquad(state, x)
93
+ update_state!(state, x, y)
94
+ y
95
+ end
96
+
97
+ # Clears internal filter state for all channels.
98
+ #
99
+ # @return [void]
100
+ def reset
101
+ @channel_states = []
102
+ end
103
+
104
+ private
105
+
106
+ def process_interleaved(samples, sample_rate:, channels:)
107
+ update_coefficients!(sample_rate)
108
+ ensure_channel_states!(channels)
109
+
110
+ output = Array.new(samples.length)
111
+ samples.each_with_index do |sample, sample_index|
112
+ channel = sample_index % channels
113
+ state = @channel_states[channel]
114
+ x = sample.to_f
115
+ y = compute_biquad(state, x)
116
+ update_state!(state, x, y)
117
+ output[sample_index] = y
118
+ end
119
+ output
120
+ end
121
+
122
+ def update_coefficients!(sample_rate)
123
+ return if @coeff_sample_rate == sample_rate && @coefficients
124
+
125
+ @coefficients = coefficients_for(sample_rate)
126
+ @coeff_sample_rate = sample_rate
127
+ reset
128
+ end
129
+
130
+ def ensure_channel_states!(channels)
131
+ return if @channel_states.length == channels
132
+
133
+ @channel_states = Array.new(channels) { { x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 } }
134
+ end
135
+
136
+ def compute_biquad(state, x)
137
+ b0, b1, b2, a1, a2 = @coefficients
138
+ (b0 * x) + (b1 * state[:x1]) + (b2 * state[:x2]) - (a1 * state[:y1]) - (a2 * state[:y2])
139
+ end
140
+
141
+ def update_state!(state, x, y)
142
+ state[:x2] = state[:x1]
143
+ state[:x1] = x
144
+ state[:y2] = state[:y1]
145
+ state[:y1] = y
146
+ end
147
+
148
+ def coefficients_for(sample_rate)
149
+ omega = 2.0 * Math::PI * @cutoff / sample_rate
150
+ cos_w = Math.cos(omega)
151
+ sin_w = Math.sin(omega)
152
+ alpha = sin_w / (2.0 * @q)
153
+ a = 10.0**(@gain_db / 40.0)
154
+
155
+ b0, b1, b2, a0, a1, a2 = case @type
156
+ when :lowpass
157
+ [
158
+ (1.0 - cos_w) / 2.0,
159
+ 1.0 - cos_w,
160
+ (1.0 - cos_w) / 2.0,
161
+ 1.0 + alpha,
162
+ -2.0 * cos_w,
163
+ 1.0 - alpha
164
+ ]
165
+ when :highpass
166
+ [
167
+ (1.0 + cos_w) / 2.0,
168
+ -(1.0 + cos_w),
169
+ (1.0 + cos_w) / 2.0,
170
+ 1.0 + alpha,
171
+ -2.0 * cos_w,
172
+ 1.0 - alpha
173
+ ]
174
+ when :bandpass
175
+ [
176
+ alpha,
177
+ 0.0,
178
+ -alpha,
179
+ 1.0 + alpha,
180
+ -2.0 * cos_w,
181
+ 1.0 - alpha
182
+ ]
183
+ when :notch
184
+ [
185
+ 1.0,
186
+ -2.0 * cos_w,
187
+ 1.0,
188
+ 1.0 + alpha,
189
+ -2.0 * cos_w,
190
+ 1.0 - alpha
191
+ ]
192
+ when :peaking
193
+ [
194
+ 1.0 + (alpha * a),
195
+ -2.0 * cos_w,
196
+ 1.0 - (alpha * a),
197
+ 1.0 + (alpha / a),
198
+ -2.0 * cos_w,
199
+ 1.0 - (alpha / a)
200
+ ]
201
+ when :lowshelf
202
+ shelf_coefficients(:low, cos_w, sin_w, a)
203
+ when :highshelf
204
+ shelf_coefficients(:high, cos_w, sin_w, a)
205
+ else
206
+ raise InvalidParameterError, "unsupported filter type: #{@type}"
207
+ end
208
+
209
+ [
210
+ b0 / a0,
211
+ b1 / a0,
212
+ b2 / a0,
213
+ a1 / a0,
214
+ a2 / a0
215
+ ]
216
+ end
217
+
218
+ def shelf_coefficients(mode, cos_w, sin_w, a)
219
+ sqrt_a = Math.sqrt(a)
220
+ two_sqrt_a_alpha = 2.0 * sqrt_a * (sin_w / 2.0)
221
+ if mode == :low
222
+ [
223
+ a * ((a + 1.0) - ((a - 1.0) * cos_w) + two_sqrt_a_alpha),
224
+ 2.0 * a * ((a - 1.0) - ((a + 1.0) * cos_w)),
225
+ a * ((a + 1.0) - ((a - 1.0) * cos_w) - two_sqrt_a_alpha),
226
+ (a + 1.0) + ((a - 1.0) * cos_w) + two_sqrt_a_alpha,
227
+ -2.0 * ((a - 1.0) + ((a + 1.0) * cos_w)),
228
+ (a + 1.0) + ((a - 1.0) * cos_w) - two_sqrt_a_alpha
229
+ ]
230
+ else
231
+ [
232
+ a * ((a + 1.0) + ((a - 1.0) * cos_w) + two_sqrt_a_alpha),
233
+ -2.0 * a * ((a - 1.0) + ((a + 1.0) * cos_w)),
234
+ a * ((a + 1.0) + ((a - 1.0) * cos_w) - two_sqrt_a_alpha),
235
+ (a + 1.0) - ((a - 1.0) * cos_w) + two_sqrt_a_alpha,
236
+ 2.0 * ((a - 1.0) - ((a + 1.0) * cos_w)),
237
+ (a + 1.0) - ((a - 1.0) * cos_w) - two_sqrt_a_alpha
238
+ ]
239
+ end
240
+ end
241
+
242
+ def validate_type!(type)
243
+ value = type.to_sym
244
+ supported = %i[lowpass highpass bandpass notch peaking lowshelf highshelf]
245
+ raise InvalidParameterError, "unsupported filter type: #{type.inspect}" unless supported.include?(value)
246
+
247
+ value
248
+ rescue NoMethodError
249
+ raise InvalidParameterError, "filter type must be Symbol/String: #{type.inspect}"
250
+ end
251
+
252
+ def validate_cutoff!(cutoff)
253
+ raise InvalidParameterError, "cutoff must be a positive Numeric" unless cutoff.is_a?(Numeric) && cutoff.positive?
254
+
255
+ cutoff.to_f
256
+ end
257
+
258
+ def validate_q!(q)
259
+ raise InvalidParameterError, "q must be a positive Numeric" unless q.is_a?(Numeric) && q.positive?
260
+
261
+ q.to_f
262
+ end
263
+
264
+ def validate_gain!(gain_db)
265
+ raise InvalidParameterError, "gain_db must be Numeric" unless gain_db.is_a?(Numeric)
266
+
267
+ gain_db.to_f
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module DSP
5
+ # Oscillator and noise source generator.
6
+ class Oscillator
7
+ # Supported waveform symbols.
8
+ WAVEFORMS = %i[sine square sawtooth triangle white_noise pink_noise].freeze
9
+
10
+ def initialize(waveform:, frequency:, amplitude: 1.0, phase: 0.0, random: Random.new)
11
+ @waveform = validate_waveform!(waveform)
12
+ @frequency = validate_frequency!(frequency)
13
+ @amplitude = validate_amplitude!(amplitude)
14
+ @phase = phase.to_f
15
+ @random = random
16
+ reset_pink_noise!
17
+ end
18
+
19
+ # Generates a finite sample buffer in the requested format.
20
+ #
21
+ # @param duration_seconds [Numeric]
22
+ # @param format [Wavify::Core::Format]
23
+ # @return [Wavify::Core::SampleBuffer]
24
+ def generate(duration_seconds, format:)
25
+ validate_format!(format)
26
+ unless duration_seconds.is_a?(Numeric) && duration_seconds >= 0
27
+ raise InvalidParameterError, "duration_seconds must be a non-negative Numeric"
28
+ end
29
+
30
+ sample_frames = (duration_seconds.to_f * format.sample_rate).round
31
+ samples = Array.new(sample_frames * format.channels)
32
+
33
+ sample_frames.times do |frame_index|
34
+ value = sample_at(frame_index, format.sample_rate)
35
+ base_index = frame_index * format.channels
36
+ format.channels.times { |channel| samples[base_index + channel] = value }
37
+ end
38
+
39
+ Core::SampleBuffer.new(samples, format)
40
+ end
41
+
42
+ # Returns an infinite enumerator of mono sample values.
43
+ #
44
+ # @param format [Wavify::Core::Format]
45
+ # @return [Enumerator<Float>]
46
+ def each_sample(format:)
47
+ validate_format!(format)
48
+
49
+ Enumerator.new do |yielder|
50
+ index = 0
51
+ loop do
52
+ yielder << sample_at(index, format.sample_rate)
53
+ index += 1
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def validate_waveform!(waveform)
61
+ value = waveform.to_sym
62
+ raise InvalidParameterError, "unsupported waveform: #{waveform.inspect}" unless WAVEFORMS.include?(value)
63
+
64
+ value
65
+ rescue NoMethodError
66
+ raise InvalidParameterError, "waveform must be Symbol/String: #{waveform.inspect}"
67
+ end
68
+
69
+ def validate_frequency!(frequency)
70
+ raise InvalidParameterError, "frequency must be a positive Numeric" unless frequency.is_a?(Numeric) && frequency.positive?
71
+
72
+ frequency.to_f
73
+ end
74
+
75
+ def validate_amplitude!(amplitude)
76
+ raise InvalidParameterError, "amplitude must be Numeric in 0.0..1.0" unless amplitude.is_a?(Numeric) && amplitude.between?(0.0, 1.0)
77
+
78
+ amplitude.to_f
79
+ end
80
+
81
+ def validate_format!(format)
82
+ raise InvalidParameterError, "format must be Core::Format" unless format.is_a?(Core::Format)
83
+ end
84
+
85
+ def sample_at(index, sample_rate)
86
+ t = @phase + (index.to_f / sample_rate)
87
+ raw = case @waveform
88
+ when :sine then Math.sin(2.0 * Math::PI * @frequency * t)
89
+ when :square then Math.sin(2.0 * Math::PI * @frequency * t) >= 0 ? 1.0 : -1.0
90
+ when :sawtooth then (2.0 * ((@frequency * t) % 1.0)) - 1.0
91
+ when :triangle then (2.0 * ((2.0 * ((@frequency * t) % 1.0)) - 1.0).abs) - 1.0
92
+ when :white_noise then @random.rand(-1.0..1.0)
93
+ when :pink_noise then next_pink_noise
94
+ end
95
+ (raw * @amplitude).clamp(-1.0, 1.0)
96
+ end
97
+
98
+ # Lightweight pink-noise approximation (Paul Kellet filter).
99
+ def next_pink_noise
100
+ white = @random.rand(-1.0..1.0)
101
+
102
+ @pink_b0 = (0.99765 * @pink_b0) + (white * 0.0990460)
103
+ @pink_b1 = (0.96300 * @pink_b1) + (white * 0.2965164)
104
+ @pink_b2 = (0.57000 * @pink_b2) + (white * 1.0526913)
105
+ @pink_b3 = (0.7616 * @pink_b3) - (white * 0.5511934)
106
+ @pink_b4 = (0.8500 * @pink_b4) - (white * 0.7616)
107
+ @pink_b5 = white * 0.115926
108
+
109
+ @pink_b0 + @pink_b1 + @pink_b2 + @pink_b3 + @pink_b4 + @pink_b5 + (white * 0.5362)
110
+ end
111
+
112
+ def reset_pink_noise!
113
+ @pink_b0 = 0.0
114
+ @pink_b1 = 0.0
115
+ @pink_b2 = 0.0
116
+ @pink_b3 = 0.0
117
+ @pink_b4 = 0.0
118
+ @pink_b5 = 0.0
119
+ end
120
+
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Base error class for all Wavify-specific exceptions.
5
+ class Error < StandardError; end
6
+
7
+ # Base class for format and codec-related errors.
8
+ class FormatError < Error; end
9
+ # Raised when input/output data is malformed for a supported format.
10
+ class InvalidFormatError < FormatError; end
11
+ # Raised when a format is recognized but not supported by the implementation.
12
+ class UnsupportedFormatError < FormatError; end
13
+ # Raised when no codec can be selected for an input/output target.
14
+ class CodecNotFoundError < FormatError; end
15
+
16
+ # Base class for processing pipeline failures.
17
+ class ProcessingError < Error; end
18
+ # Raised when sample buffer conversion fails.
19
+ class BufferConversionError < ProcessingError; end
20
+ # Raised when a streaming operation cannot proceed.
21
+ class StreamError < ProcessingError; end
22
+
23
+ # Base class for DSP parameter/processing errors.
24
+ class DSPError < Error; end
25
+ # Raised when method parameters are invalid.
26
+ class InvalidParameterError < DSPError; end
27
+
28
+ # Base class for sequencer and DSL-related errors.
29
+ class SequencerError < Error; end
30
+ # Raised when rhythmic pattern notation is invalid.
31
+ class InvalidPatternError < SequencerError; end
32
+ # Raised when note/chord notation is invalid.
33
+ class InvalidNoteError < SequencerError; end
34
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Sequencer
5
+ # Schedules sequencer tracks and renders them into audio.
6
+ class Engine
7
+ # Default beats-per-bar value used when omitted.
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)
13
+ @tempo = validate_tempo!(tempo)
14
+ @format = validate_format!(format)
15
+ @beats_per_bar = validate_beats_per_bar!(beats_per_bar)
16
+ end
17
+
18
+ # @return [Float] seconds per beat at the current tempo
19
+ def seconds_per_beat
20
+ 60.0 / @tempo
21
+ end
22
+
23
+ # @return [Float] duration of one bar in seconds
24
+ def bar_duration_seconds
25
+ seconds_per_beat * @beats_per_bar
26
+ end
27
+
28
+ def step_duration_seconds(resolution)
29
+ raise SequencerError, "resolution must be a positive Integer" unless resolution.is_a?(Integer) && resolution.positive?
30
+
31
+ bar_duration_seconds / resolution.to_f
32
+ end
33
+
34
+ def timeline_for_track(track, bars:, start_bar: 0)
35
+ 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?
37
+ raise SequencerError, "start_bar must be a non-negative Integer" unless start_bar.is_a?(Integer) && start_bar >= 0
38
+
39
+ 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] }
44
+ end
45
+
46
+ # Builds a combined event timeline for tracks and optional arrangement.
47
+ #
48
+ # @param tracks [Array<Wavify::Sequencer::Track>]
49
+ # @param arrangement [Array<Hash>, nil]
50
+ # @param default_bars [Integer]
51
+ # @return [Array<Hash>]
52
+ def build_timeline(tracks:, arrangement: nil, default_bars: 1)
53
+ track_map = normalize_tracks(tracks)
54
+
55
+ if arrangement
56
+ build_arranged_timeline(track_map, arrangement)
57
+ else
58
+ raise SequencerError, "default_bars must be a positive Integer" unless default_bars.is_a?(Integer) && default_bars.positive?
59
+
60
+ events = track_map.values.flat_map do |track|
61
+ timeline_for_track(track, bars: default_bars)
62
+ end
63
+ events.sort_by { |event| [event[:start_time], event[:track].to_s] }
64
+ end
65
+ end
66
+
67
+ # Renders tracks into a mixed audio object.
68
+ #
69
+ # @param tracks [Array<Wavify::Sequencer::Track>]
70
+ # @param arrangement [Array<Hash>, nil]
71
+ # @param default_bars [Integer]
72
+ # @return [Wavify::Audio]
73
+ def render(tracks:, arrangement: nil, default_bars: 1)
74
+ track_map = normalize_tracks(tracks)
75
+ sections = arrangement ? normalize_arrangement(arrangement, track_map) : nil
76
+
77
+ rendered_audios = if sections
78
+ render_arranged_tracks(track_map, sections)
79
+ else
80
+ track_map.values.filter_map { |track| render_track_audio(track, bars: default_bars) }
81
+ end
82
+
83
+ return Wavify::Audio.silence(0.0, format: @format) if rendered_audios.empty?
84
+
85
+ Wavify::Audio.mix(*rendered_audios)
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_tracks(tracks)
91
+ list = tracks.is_a?(Array) ? tracks : Array(tracks)
92
+ raise SequencerError, "tracks must not be empty" if list.empty?
93
+
94
+ list.each_with_object({}) do |track, map|
95
+ raise SequencerError, "track must be a Sequencer::Track" unless track.is_a?(Track)
96
+
97
+ map[track.name] = track
98
+ end
99
+ end
100
+
101
+ def normalize_arrangement(arrangement, track_map)
102
+ raise SequencerError, "arrangement must be an Array" unless arrangement.is_a?(Array)
103
+
104
+ cursor_bar = 0
105
+ arrangement.map do |section|
106
+ raise SequencerError, "section must be a Hash" unless section.is_a?(Hash)
107
+
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?
111
+
112
+ track_names = Array(section.fetch(:tracks)).map(&:to_sym)
113
+ raise SequencerError, "section tracks must not be empty" if track_names.empty?
114
+
115
+ unknown = track_names - track_map.keys
116
+ raise SequencerError, "unknown tracks in section #{name}: #{unknown.join(', ')}" unless unknown.empty?
117
+
118
+ normalized = { name: name, bars: bars, tracks: track_names, start_bar: cursor_bar }
119
+ cursor_bar += bars
120
+ normalized
121
+ end
122
+ end
123
+
124
+ def build_arranged_timeline(track_map, arrangement)
125
+ sections = normalize_arrangement(arrangement, track_map)
126
+ 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])
129
+ end
130
+ end
131
+ events.sort_by { |event| [event[:start_time], event[:track].to_s] }
132
+ end
133
+
134
+ 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])
138
+ end
139
+ end
140
+ end
141
+
142
+ def render_track_audio(track, bars:, start_bar: 0)
143
+ events = timeline_for_track(track, bars: bars, start_bar: start_bar)
144
+ note_events = events.select { |event| %i[note chord].include?(event[:kind]) }
145
+ return nil if note_events.empty?
146
+
147
+ 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
151
+
152
+ note_events.each do |event|
153
+ 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
164
+ end
165
+
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)))
168
+ 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
170
+ 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
183
+ end
184
+ end
185
+
186
+ Wavify::Audio.mix(*note_audios)
187
+ end
188
+
189
+ def schedule_pattern_events(track, bars:, start_bar:)
190
+ step_duration = step_duration_seconds(track.pattern.length)
191
+
192
+ (0...bars).flat_map do |bar_offset|
193
+ track.pattern.filter(&:trigger?).map do |step|
194
+ 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
+ }
205
+ end
206
+ end
207
+ end
208
+
209
+ def schedule_note_events(track, bars:, start_bar:)
210
+ base_step_duration = step_duration_seconds(track.note_resolution)
211
+
212
+ (0...bars).flat_map do |bar_offset|
213
+ track.note_sequence.each_with_object([]) do |event, result|
214
+ next if event.rest?
215
+
216
+ absolute_bar = start_bar + bar_offset
217
+ start_time = (absolute_bar * bar_duration_seconds) + (event.index * base_step_duration)
218
+ result << {
219
+ kind: :note,
220
+ track: track.name,
221
+ bar: absolute_bar,
222
+ step_index: event.index,
223
+ start_time: start_time,
224
+ duration: base_step_duration,
225
+ midi_notes: [event.midi_note]
226
+ }
227
+ end
228
+ end
229
+ end
230
+
231
+ def schedule_chord_events(track, bars:, start_bar:)
232
+ progression = track.chord_progression
233
+ return [] unless progression
234
+
235
+ (0...bars).map do |bar_offset|
236
+ chord = progression[bar_offset % progression.length]
237
+ absolute_bar = start_bar + bar_offset
238
+ {
239
+ kind: :chord,
240
+ track: track.name,
241
+ bar: absolute_bar,
242
+ step_index: 0,
243
+ start_time: absolute_bar * bar_duration_seconds,
244
+ duration: bar_duration_seconds,
245
+ midi_notes: chord.fetch(:midi_notes),
246
+ chord: chord.fetch(:token)
247
+ }
248
+ end
249
+ end
250
+
251
+ def midi_to_frequency(midi_note)
252
+ 440.0 * (2.0**((midi_note - 69) / 12.0))
253
+ end
254
+
255
+ def apply_track_effects(audio, effects)
256
+ effects.reduce(audio) { |current, effect| current.apply(effect) }
257
+ end
258
+
259
+ def validate_tempo!(tempo)
260
+ raise SequencerError, "tempo must be a positive Numeric" unless tempo.is_a?(Numeric) && tempo.positive?
261
+
262
+ tempo.to_f
263
+ end
264
+
265
+ def validate_format!(format)
266
+ raise SequencerError, "format must be a Core::Format" unless format.is_a?(Wavify::Core::Format)
267
+
268
+ format
269
+ end
270
+
271
+ def validate_beats_per_bar!(beats_per_bar)
272
+ raise SequencerError, "beats_per_bar must be a positive Integer" unless beats_per_bar.is_a?(Integer) && beats_per_bar.positive?
273
+
274
+ beats_per_bar
275
+ end
276
+ end
277
+ end
278
+ end