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.
- checksums.yaml +7 -0
- data/.serena/.gitignore +1 -0
- data/.serena/memories/project_overview.md +5 -0
- data/.serena/memories/style_and_completion.md +5 -0
- data/.serena/memories/suggested_commands.md +11 -0
- data/.serena/project.yml +126 -0
- data/.simplecov +18 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/Rakefile +190 -0
- data/benchmarks/README.md +46 -0
- data/benchmarks/benchmark_helper.rb +112 -0
- data/benchmarks/dsp_effects_benchmark.rb +46 -0
- data/benchmarks/flac_benchmark.rb +74 -0
- data/benchmarks/streaming_memory_benchmark.rb +94 -0
- data/benchmarks/wav_io_benchmark.rb +110 -0
- data/examples/audio_processing.rb +73 -0
- data/examples/cinematic_transition.rb +118 -0
- data/examples/drum_machine.rb +74 -0
- data/examples/format_convert.rb +81 -0
- data/examples/hybrid_arrangement.rb +165 -0
- data/examples/streaming_master_chain.rb +129 -0
- data/examples/synth_pad.rb +42 -0
- data/lib/wavify/audio.rb +483 -0
- data/lib/wavify/codecs/aiff.rb +338 -0
- data/lib/wavify/codecs/base.rb +108 -0
- data/lib/wavify/codecs/flac.rb +1322 -0
- data/lib/wavify/codecs/ogg_vorbis.rb +1447 -0
- data/lib/wavify/codecs/raw.rb +193 -0
- data/lib/wavify/codecs/registry.rb +87 -0
- data/lib/wavify/codecs/wav.rb +459 -0
- data/lib/wavify/core/duration.rb +99 -0
- data/lib/wavify/core/format.rb +133 -0
- data/lib/wavify/core/sample_buffer.rb +216 -0
- data/lib/wavify/core/stream.rb +129 -0
- data/lib/wavify/dsl.rb +537 -0
- data/lib/wavify/dsp/effects/chorus.rb +98 -0
- data/lib/wavify/dsp/effects/compressor.rb +85 -0
- data/lib/wavify/dsp/effects/delay.rb +69 -0
- data/lib/wavify/dsp/effects/distortion.rb +64 -0
- data/lib/wavify/dsp/effects/effect_base.rb +68 -0
- data/lib/wavify/dsp/effects/reverb.rb +112 -0
- data/lib/wavify/dsp/effects.rb +21 -0
- data/lib/wavify/dsp/envelope.rb +97 -0
- data/lib/wavify/dsp/filter.rb +271 -0
- data/lib/wavify/dsp/oscillator.rb +123 -0
- data/lib/wavify/errors.rb +34 -0
- data/lib/wavify/sequencer/engine.rb +278 -0
- data/lib/wavify/sequencer/note_sequence.rb +132 -0
- data/lib/wavify/sequencer/pattern.rb +102 -0
- data/lib/wavify/sequencer/track.rb +298 -0
- data/lib/wavify/sequencer.rb +12 -0
- data/lib/wavify/version.rb +6 -0
- data/lib/wavify.rb +28 -0
- data/tools/fixture_writer.rb +85 -0
- metadata +129 -0
data/lib/wavify/dsl.rb
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
# Declarative music-building DSL that compiles to sequencer tracks and audio.
|
|
5
|
+
module DSL
|
|
6
|
+
# Immutable compiled song definition returned by {DSL.build_definition}.
|
|
7
|
+
class SongDefinition
|
|
8
|
+
# Arrangement section metadata (`name`, `bars`, active `tracks`).
|
|
9
|
+
Section = Struct.new(:name, :bars, :tracks, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
attr_reader :format, :tempo, :beats_per_bar, :tracks, :sections
|
|
12
|
+
|
|
13
|
+
def initialize(format:, tempo:, beats_per_bar:, tracks:, sections:)
|
|
14
|
+
@format = format
|
|
15
|
+
@tempo = tempo
|
|
16
|
+
@beats_per_bar = beats_per_bar
|
|
17
|
+
@tracks = tracks.freeze
|
|
18
|
+
@sections = sections.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def arrangement?
|
|
22
|
+
!@sections.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a sequencer engine configured from the song definition.
|
|
26
|
+
#
|
|
27
|
+
# @return [Wavify::Sequencer::Engine]
|
|
28
|
+
def engine
|
|
29
|
+
Wavify::Sequencer::Engine.new(
|
|
30
|
+
tempo: @tempo,
|
|
31
|
+
format: @format,
|
|
32
|
+
beats_per_bar: @beats_per_bar
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns sequencer tracks converted from DSL track definitions.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Wavify::Sequencer::Track>]
|
|
39
|
+
def sequencer_tracks
|
|
40
|
+
@tracks.map(&:to_sequencer_track)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns arrangement sections as hashes accepted by the sequencer engine.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<Hash>]
|
|
46
|
+
def arrangement
|
|
47
|
+
@sections.map do |section|
|
|
48
|
+
{ name: section.name, bars: section.bars, tracks: section.tracks }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Builds a sequencer event timeline without rendering audio.
|
|
53
|
+
#
|
|
54
|
+
# @param default_bars [Integer]
|
|
55
|
+
# @return [Array<Hash>]
|
|
56
|
+
def timeline(default_bars: 1)
|
|
57
|
+
engine.build_timeline(
|
|
58
|
+
tracks: sequencer_tracks,
|
|
59
|
+
arrangement: arrangement? ? arrangement : nil,
|
|
60
|
+
default_bars: default_bars
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Renders the song definition to an {Wavify::Audio} instance.
|
|
65
|
+
#
|
|
66
|
+
# @param default_bars [Integer]
|
|
67
|
+
# @return [Wavify::Audio]
|
|
68
|
+
def render(default_bars: 1)
|
|
69
|
+
sequencer_audio = engine.render(
|
|
70
|
+
tracks: sequencer_tracks,
|
|
71
|
+
arrangement: arrangement? ? arrangement : nil,
|
|
72
|
+
default_bars: default_bars
|
|
73
|
+
)
|
|
74
|
+
sample_audio = render_sample_tracks(default_bars: default_bars)
|
|
75
|
+
|
|
76
|
+
return sequencer_audio unless sample_audio
|
|
77
|
+
return sample_audio if sequencer_audio.sample_frame_count.zero?
|
|
78
|
+
|
|
79
|
+
Wavify::Audio.mix(sequencer_audio, sample_audio)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Renders and writes the song to disk.
|
|
83
|
+
#
|
|
84
|
+
# @param path [String]
|
|
85
|
+
# @param default_bars [Integer]
|
|
86
|
+
# @return [Wavify::Audio]
|
|
87
|
+
def write(path, default_bars: 1)
|
|
88
|
+
render(default_bars: default_bars).write(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def render_sample_tracks(default_bars:)
|
|
94
|
+
rendered_tracks = @tracks.filter_map do |track|
|
|
95
|
+
render_sample_track(track, default_bars: default_bars)
|
|
96
|
+
end
|
|
97
|
+
return nil if rendered_tracks.empty?
|
|
98
|
+
|
|
99
|
+
Wavify::Audio.mix(*rendered_tracks)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def render_sample_track(track, default_bars:)
|
|
103
|
+
patterns = track.sample_pattern_map
|
|
104
|
+
return nil if patterns.empty?
|
|
105
|
+
|
|
106
|
+
sections = active_sections_for(track.name, default_bars: default_bars)
|
|
107
|
+
return nil if sections.empty?
|
|
108
|
+
|
|
109
|
+
work_format = track_render_work_format
|
|
110
|
+
sample_cache = {}
|
|
111
|
+
events = []
|
|
112
|
+
|
|
113
|
+
sections.each do |section|
|
|
114
|
+
patterns.each do |sample_key, pattern|
|
|
115
|
+
sample_audio = sample_cache[sample_key] ||= load_sample_audio(track, sample_key, work_format)
|
|
116
|
+
step_duration = engine.step_duration_seconds(pattern.length)
|
|
117
|
+
|
|
118
|
+
(0...section.fetch(:bars)).each do |bar_offset|
|
|
119
|
+
absolute_bar = section.fetch(:start_bar) + bar_offset
|
|
120
|
+
bar_base_time = absolute_bar * engine.bar_duration_seconds
|
|
121
|
+
|
|
122
|
+
pattern.each do |step|
|
|
123
|
+
next unless step.trigger?
|
|
124
|
+
|
|
125
|
+
events << {
|
|
126
|
+
sample_key: sample_key,
|
|
127
|
+
start_time: bar_base_time + (step.index * step_duration),
|
|
128
|
+
velocity: step.velocity,
|
|
129
|
+
sample_audio: sample_audio
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
return nil if events.empty?
|
|
137
|
+
|
|
138
|
+
total_end_time = events.map { |event| event[:start_time] + event.fetch(:sample_audio).duration.total_seconds }.max || 0.0
|
|
139
|
+
base_audio = Wavify::Audio.silence(total_end_time, format: work_format)
|
|
140
|
+
mixed = base_audio.buffer.samples.dup
|
|
141
|
+
|
|
142
|
+
events.each do |event|
|
|
143
|
+
overlay_sample_event!(mixed, event, work_format)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
mixed.map! { |sample| sample.clamp(-1.0, 1.0) }
|
|
147
|
+
rendered = Wavify::Audio.new(Wavify::Core::SampleBuffer.new(mixed, work_format))
|
|
148
|
+
rendered = rendered.gain(track.gain_db) if track.gain_db != 0.0
|
|
149
|
+
rendered = rendered.pan(track.pan_position) if track.pan_position != 0.0
|
|
150
|
+
rendered = track.effect_processors.reduce(rendered) { |audio, effect| audio.apply(effect) }
|
|
151
|
+
rendered.convert(@format)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def active_sections_for(track_name, default_bars:)
|
|
155
|
+
if arrangement?
|
|
156
|
+
cursor_bar = 0
|
|
157
|
+
@sections.each_with_object([]) do |section, result|
|
|
158
|
+
result << { bars: section.bars, start_bar: cursor_bar } if section.tracks.include?(track_name)
|
|
159
|
+
cursor_bar += section.bars
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
[{ bars: default_bars, start_bar: 0 }]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def track_render_work_format
|
|
167
|
+
base = @format.channels == 2 ? @format : @format.with(channels: 2)
|
|
168
|
+
base.with(sample_format: :float, bit_depth: 32)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def load_sample_audio(track, sample_key, work_format)
|
|
172
|
+
path = track.samples[sample_key]
|
|
173
|
+
raise Wavify::SequencerError, "missing sample mapping for pattern #{sample_key.inspect} on track #{track.name}" unless path
|
|
174
|
+
|
|
175
|
+
Wavify::Audio.read(path).convert(work_format)
|
|
176
|
+
rescue Wavify::Error => e
|
|
177
|
+
raise Wavify::SequencerError, "failed to load sample #{path.inspect} for track #{track.name}: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def overlay_sample_event!(mixed, event, work_format)
|
|
181
|
+
sample_audio = event.fetch(:sample_audio)
|
|
182
|
+
velocity = event.fetch(:velocity).to_f
|
|
183
|
+
start_frame = (event.fetch(:start_time) * work_format.sample_rate).round
|
|
184
|
+
start_index = start_frame * work_format.channels
|
|
185
|
+
|
|
186
|
+
sample_audio.buffer.samples.each_with_index do |sample, index|
|
|
187
|
+
target_index = start_index + index
|
|
188
|
+
break if target_index >= mixed.length
|
|
189
|
+
|
|
190
|
+
mixed[target_index] += sample * velocity
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# :nodoc: all
|
|
197
|
+
# @api private
|
|
198
|
+
class TrackDefinition
|
|
199
|
+
# Internal mapping from DSL effect names to effect class constants.
|
|
200
|
+
EFFECT_CLASS_NAMES = {
|
|
201
|
+
delay: "Delay",
|
|
202
|
+
reverb: "Reverb",
|
|
203
|
+
chorus: "Chorus",
|
|
204
|
+
distortion: "Distortion",
|
|
205
|
+
compressor: "Compressor"
|
|
206
|
+
}.freeze
|
|
207
|
+
|
|
208
|
+
# Internal mutable track state compiled from DSL blocks.
|
|
209
|
+
#
|
|
210
|
+
# Readers are used by {SongDefinition} rendering and sequencer conversion.
|
|
211
|
+
attr_reader :name, :waveform, :gain_db, :pan_position, :pattern_resolution, :note_resolution,
|
|
212
|
+
:default_octave, :envelope, :notes_notation, :chords_notation, :effects, :samples,
|
|
213
|
+
:named_patterns, :synth_options
|
|
214
|
+
|
|
215
|
+
def initialize(name)
|
|
216
|
+
@name = name.to_sym
|
|
217
|
+
@waveform = :sine
|
|
218
|
+
@gain_db = 0.0
|
|
219
|
+
@pan_position = 0.0
|
|
220
|
+
@pattern_resolution = 16
|
|
221
|
+
@note_resolution = 8
|
|
222
|
+
@default_octave = 4
|
|
223
|
+
@envelope = nil
|
|
224
|
+
@notes_notation = nil
|
|
225
|
+
@chords_notation = nil
|
|
226
|
+
@effects = []
|
|
227
|
+
@samples = {}
|
|
228
|
+
@named_patterns = {}
|
|
229
|
+
@primary_pattern = nil
|
|
230
|
+
@synth_options = {}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns the primary pattern notation for sequencer rendering.
|
|
234
|
+
def primary_pattern
|
|
235
|
+
@primary_pattern || @named_patterns.values.first
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Registers a primary or named rhythm pattern.
|
|
239
|
+
def pattern!(name_or_notation, notation = nil, resolution: nil)
|
|
240
|
+
if notation.nil?
|
|
241
|
+
@primary_pattern = name_or_notation.to_s
|
|
242
|
+
else
|
|
243
|
+
@named_patterns[name_or_notation.to_sym] = notation.to_s
|
|
244
|
+
end
|
|
245
|
+
@pattern_resolution = resolution if resolution
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Stores note notation and optional parser settings.
|
|
249
|
+
def notes!(notation, resolution: nil, default_octave: nil)
|
|
250
|
+
@notes_notation = notation.to_s
|
|
251
|
+
@note_resolution = resolution if resolution
|
|
252
|
+
@default_octave = default_octave if default_octave
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Stores chord progression notation and optional octave override.
|
|
256
|
+
def chords!(notation, default_octave: nil)
|
|
257
|
+
@chords_notation = notation
|
|
258
|
+
@default_octave = default_octave if default_octave
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Registers a sample path keyed by a symbolic name.
|
|
262
|
+
def sample!(key, path)
|
|
263
|
+
@samples[key.to_sym] = path.to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Configures synth waveform and generator options.
|
|
267
|
+
def synth!(waveform, **options)
|
|
268
|
+
@waveform = waveform.to_sym
|
|
269
|
+
@synth_options.merge!(options)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Sets gain (dB) applied after rendering the track.
|
|
273
|
+
def gain!(db)
|
|
274
|
+
@gain_db = db.to_f
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Sets stereo pan position (-1.0..1.0).
|
|
278
|
+
def pan!(position)
|
|
279
|
+
@pan_position = position.to_f
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Builds and stores an ADSR envelope for sequencer notes.
|
|
283
|
+
def envelope!(attack:, decay:, sustain:, release:)
|
|
284
|
+
@envelope = Wavify::DSP::Envelope.new(
|
|
285
|
+
attack: attack,
|
|
286
|
+
decay: decay,
|
|
287
|
+
sustain: sustain,
|
|
288
|
+
release: release
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Appends an effect configuration to the track.
|
|
293
|
+
def effect!(name, **params)
|
|
294
|
+
@effects << { name: name.to_sym, params: params }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Converts DSL settings into a {Wavify::Sequencer::Track}.
|
|
298
|
+
def to_sequencer_track
|
|
299
|
+
Wavify::Sequencer::Track.new(
|
|
300
|
+
@name,
|
|
301
|
+
pattern: primary_pattern,
|
|
302
|
+
note_sequence: @notes_notation,
|
|
303
|
+
chord_progression: @chords_notation,
|
|
304
|
+
waveform: @waveform,
|
|
305
|
+
gain_db: @gain_db,
|
|
306
|
+
pan_position: @pan_position,
|
|
307
|
+
pattern_resolution: @pattern_resolution,
|
|
308
|
+
note_resolution: @note_resolution,
|
|
309
|
+
default_octave: @default_octave,
|
|
310
|
+
envelope: @envelope,
|
|
311
|
+
effects: effect_processors
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Builds sample trigger patterns from named/primary pattern notation.
|
|
316
|
+
def sample_pattern_map
|
|
317
|
+
patterns = {}
|
|
318
|
+
@named_patterns.each do |name, notation|
|
|
319
|
+
patterns[name] = Wavify::Sequencer::Pattern.new(notation, resolution: @pattern_resolution)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
if patterns.empty? && @primary_pattern && @samples.length == 1
|
|
323
|
+
patterns[@samples.keys.first] = Wavify::Sequencer::Pattern.new(@primary_pattern, resolution: @pattern_resolution)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
patterns
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Instantiates configured effect processor objects.
|
|
330
|
+
def effect_processors
|
|
331
|
+
@effects.map do |effect|
|
|
332
|
+
effect_class_name = EFFECT_CLASS_NAMES[effect.fetch(:name)]
|
|
333
|
+
raise Wavify::SequencerError, "unsupported effect: #{effect.fetch(:name)}" unless effect_class_name
|
|
334
|
+
|
|
335
|
+
Wavify::Effects.const_get(effect_class_name).new(**effect.fetch(:params))
|
|
336
|
+
rescue NameError
|
|
337
|
+
raise Wavify::SequencerError, "effect class not found: #{effect_class_name}"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# :nodoc: all
|
|
343
|
+
# @api private
|
|
344
|
+
class TrackBuilder
|
|
345
|
+
# @param track_definition [TrackDefinition]
|
|
346
|
+
def initialize(track_definition)
|
|
347
|
+
@track = track_definition
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Delegates pattern definition to the underlying track.
|
|
351
|
+
def pattern(name_or_notation, notation = nil, resolution: nil)
|
|
352
|
+
@track.pattern!(name_or_notation, notation, resolution: resolution)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Registers a sample mapping.
|
|
356
|
+
def sample(name, path)
|
|
357
|
+
@track.sample!(name, path)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Configures synth waveform/options.
|
|
361
|
+
def synth(waveform, **options)
|
|
362
|
+
@track.synth!(waveform, **options)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Defines note notation and parser options.
|
|
366
|
+
def notes(notation, resolution: nil, default_octave: nil)
|
|
367
|
+
@track.notes!(notation, resolution: resolution, default_octave: default_octave)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Defines chord notation.
|
|
371
|
+
def chords(notation, default_octave: nil)
|
|
372
|
+
@track.chords!(notation, default_octave: default_octave)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Defines an ADSR envelope and validates required parameters.
|
|
376
|
+
def envelope(**params)
|
|
377
|
+
required = %i[attack decay sustain release]
|
|
378
|
+
missing = required - params.keys
|
|
379
|
+
raise Wavify::SequencerError, "missing envelope params: #{missing.join(', ')}" unless missing.empty?
|
|
380
|
+
|
|
381
|
+
@track.envelope!(**params.slice(*required))
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Adds an effect to the track.
|
|
385
|
+
def effect(name, **params)
|
|
386
|
+
@track.effect!(name, **params)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Sets track gain in dB.
|
|
390
|
+
def gain(db)
|
|
391
|
+
@track.gain!(db)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Sets track pan position.
|
|
395
|
+
def pan(position)
|
|
396
|
+
@track.pan!(position)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# :nodoc: all
|
|
401
|
+
# @api private
|
|
402
|
+
class ArrangementBuilder
|
|
403
|
+
attr_reader :sections
|
|
404
|
+
|
|
405
|
+
def initialize
|
|
406
|
+
@sections = []
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def section(name, bars:, tracks:)
|
|
410
|
+
raise Wavify::SequencerError, "bars must be a positive Integer" unless bars.is_a?(Integer) && bars.positive?
|
|
411
|
+
|
|
412
|
+
names = Array(tracks).map(&:to_sym)
|
|
413
|
+
raise Wavify::SequencerError, "tracks must not be empty" if names.empty?
|
|
414
|
+
|
|
415
|
+
@sections << SongDefinition::Section.new(name: name.to_sym, bars: bars, tracks: names)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# :nodoc: all
|
|
420
|
+
# @api private
|
|
421
|
+
class Builder
|
|
422
|
+
# Internal builder state used by the public {DSL.build_definition} entrypoint.
|
|
423
|
+
attr_reader :format, :default_bars
|
|
424
|
+
|
|
425
|
+
# Compiles a DSL block into a {SongDefinition}.
|
|
426
|
+
#
|
|
427
|
+
# @param format [Wavify::Core::Format]
|
|
428
|
+
# @param tempo [Numeric]
|
|
429
|
+
# @param beats_per_bar [Integer]
|
|
430
|
+
# @param default_bars [Integer]
|
|
431
|
+
# @return [SongDefinition]
|
|
432
|
+
def self.build_definition(format:, tempo: 120, beats_per_bar: 4, default_bars: 1, &block)
|
|
433
|
+
builder = new(format: format, tempo: tempo, beats_per_bar: beats_per_bar, default_bars: default_bars)
|
|
434
|
+
builder.instance_eval(&block) if block
|
|
435
|
+
builder.to_song_definition
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def initialize(format:, tempo: 120, beats_per_bar: 4, default_bars: 1)
|
|
439
|
+
raise Wavify::InvalidParameterError, "format must be Core::Format" unless format.is_a?(Wavify::Core::Format)
|
|
440
|
+
|
|
441
|
+
@format = format
|
|
442
|
+
@tempo = tempo.to_f
|
|
443
|
+
@beats_per_bar = beats_per_bar
|
|
444
|
+
@default_bars = default_bars
|
|
445
|
+
@track_definitions = []
|
|
446
|
+
@arrangement_sections = []
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def tempo(value)
|
|
450
|
+
raise Wavify::SequencerError, "tempo must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
|
|
451
|
+
|
|
452
|
+
@tempo = value.to_f
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def beats_per_bar(value)
|
|
456
|
+
raise Wavify::SequencerError, "beats_per_bar must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
457
|
+
|
|
458
|
+
@beats_per_bar = value
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def bars(value)
|
|
462
|
+
raise Wavify::SequencerError, "bars must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
463
|
+
|
|
464
|
+
@default_bars = value
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Defines a track block and stores its compiled configuration.
|
|
468
|
+
def track(name, &block)
|
|
469
|
+
definition = TrackDefinition.new(name)
|
|
470
|
+
TrackBuilder.new(definition).instance_eval(&block) if block
|
|
471
|
+
@track_definitions << definition
|
|
472
|
+
definition
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Defines arrangement sections for selective track playback by section.
|
|
476
|
+
def arrange(&block)
|
|
477
|
+
builder = ArrangementBuilder.new
|
|
478
|
+
builder.instance_eval(&block) if block
|
|
479
|
+
@arrangement_sections = builder.sections
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Finalizes and returns an immutable {SongDefinition}.
|
|
483
|
+
def to_song_definition
|
|
484
|
+
SongDefinition.new(
|
|
485
|
+
format: @format,
|
|
486
|
+
tempo: @tempo,
|
|
487
|
+
beats_per_bar: @beats_per_bar,
|
|
488
|
+
tracks: @track_definitions,
|
|
489
|
+
sections: @arrangement_sections
|
|
490
|
+
)
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
class << self
|
|
495
|
+
# Public DSL entry that returns a compiled {SongDefinition}.
|
|
496
|
+
#
|
|
497
|
+
# @param format [Wavify::Core::Format]
|
|
498
|
+
# @param tempo [Numeric]
|
|
499
|
+
# @param beats_per_bar [Integer]
|
|
500
|
+
# @param default_bars [Integer]
|
|
501
|
+
# @return [SongDefinition]
|
|
502
|
+
def build_definition(format:, tempo: 120, beats_per_bar: 4, default_bars: 1, &block)
|
|
503
|
+
Builder.build_definition(
|
|
504
|
+
format: format,
|
|
505
|
+
tempo: tempo,
|
|
506
|
+
beats_per_bar: beats_per_bar,
|
|
507
|
+
default_bars: default_bars,
|
|
508
|
+
&block
|
|
509
|
+
)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
class << self
|
|
515
|
+
# Renders audio from the DSL and optionally writes it to disk.
|
|
516
|
+
#
|
|
517
|
+
# @param output_path [String, nil]
|
|
518
|
+
# @param format [Core::Format]
|
|
519
|
+
# @param tempo [Numeric]
|
|
520
|
+
# @param beats_per_bar [Integer]
|
|
521
|
+
# @param default_bars [Integer]
|
|
522
|
+
# @return [Audio]
|
|
523
|
+
def build(output_path = nil, format: Core::Format::CD_QUALITY, tempo: 120, beats_per_bar: 4, default_bars: 1, &block)
|
|
524
|
+
song = DSL.build_definition(
|
|
525
|
+
format: format,
|
|
526
|
+
tempo: tempo,
|
|
527
|
+
beats_per_bar: beats_per_bar,
|
|
528
|
+
default_bars: default_bars,
|
|
529
|
+
&block
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
audio = song.render(default_bars: default_bars)
|
|
533
|
+
audio.write(output_path) if output_path
|
|
534
|
+
audio
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavify
|
|
4
|
+
module DSP
|
|
5
|
+
module Effects
|
|
6
|
+
# Modulated delay chorus effect.
|
|
7
|
+
class Chorus < EffectBase
|
|
8
|
+
def initialize(rate: 1.0, depth: 0.5, mix: 0.5)
|
|
9
|
+
super()
|
|
10
|
+
@rate = validate_positive!(rate, :rate)
|
|
11
|
+
@depth = validate_unit!(depth, :depth)
|
|
12
|
+
@mix = validate_unit!(mix, :mix)
|
|
13
|
+
reset
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Processes a single sample for one channel.
|
|
17
|
+
#
|
|
18
|
+
# @param sample [Numeric]
|
|
19
|
+
# @param channel [Integer]
|
|
20
|
+
# @param sample_rate [Integer]
|
|
21
|
+
# @return [Float]
|
|
22
|
+
def process_sample(sample, channel:, sample_rate:)
|
|
23
|
+
dry = sample.to_f
|
|
24
|
+
line = @delay_lines.fetch(channel)
|
|
25
|
+
write_index = @write_indices.fetch(channel)
|
|
26
|
+
|
|
27
|
+
mod_phase = @lfo_phase + channel_phase_offset(channel)
|
|
28
|
+
mod = Math.sin(mod_phase)
|
|
29
|
+
delay_samples = @base_delay_samples + (@depth_delay_samples * ((mod + 1.0) / 2.0))
|
|
30
|
+
wet = read_fractional_delay(line, write_index, delay_samples)
|
|
31
|
+
|
|
32
|
+
line[write_index] = dry
|
|
33
|
+
@write_indices[channel] = (write_index + 1) % line.length
|
|
34
|
+
advance_lfo!
|
|
35
|
+
|
|
36
|
+
(dry * (1.0 - @mix)) + (wet * @mix)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def prepare_runtime_state(sample_rate:, channels:)
|
|
42
|
+
max_delay_seconds = 0.03
|
|
43
|
+
@base_delay_samples = [(sample_rate * 0.012).round, 1].max
|
|
44
|
+
@depth_delay_samples = (sample_rate * max_delay_seconds * @depth * 0.6).to_f
|
|
45
|
+
line_length = [(@base_delay_samples + @depth_delay_samples.ceil + 3), 8].max
|
|
46
|
+
|
|
47
|
+
@delay_lines = Array.new(channels) { Array.new(line_length, 0.0) }
|
|
48
|
+
@write_indices = Array.new(channels, 0)
|
|
49
|
+
@lfo_phase = 0.0
|
|
50
|
+
@lfo_step = (2.0 * Math::PI * @rate) / (sample_rate * channels)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset_runtime_state
|
|
54
|
+
@delay_lines = []
|
|
55
|
+
@write_indices = []
|
|
56
|
+
@base_delay_samples = nil
|
|
57
|
+
@depth_delay_samples = nil
|
|
58
|
+
@lfo_phase = 0.0
|
|
59
|
+
@lfo_step = 0.0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_fractional_delay(line, write_index, delay_samples)
|
|
63
|
+
integer = delay_samples.floor
|
|
64
|
+
fraction = delay_samples - integer
|
|
65
|
+
|
|
66
|
+
idx_a = (write_index - integer - 1) % line.length
|
|
67
|
+
idx_b = (idx_a - 1) % line.length
|
|
68
|
+
a = line[idx_a]
|
|
69
|
+
b = line[idx_b]
|
|
70
|
+
a + ((b - a) * fraction)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def channel_phase_offset(channel)
|
|
74
|
+
return 0.0 if @runtime_channels.nil? || @runtime_channels <= 1
|
|
75
|
+
|
|
76
|
+
(2.0 * Math::PI * channel) / @runtime_channels
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def advance_lfo!
|
|
80
|
+
@lfo_phase += @lfo_step
|
|
81
|
+
@lfo_phase -= (2.0 * Math::PI) if @lfo_phase >= (2.0 * Math::PI)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_positive!(value, name)
|
|
85
|
+
raise InvalidParameterError, "#{name} must be a positive Numeric" unless value.is_a?(Numeric) && value.positive?
|
|
86
|
+
|
|
87
|
+
value.to_f
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_unit!(value, name)
|
|
91
|
+
raise InvalidParameterError, "#{name} must be Numeric in 0.0..1.0" unless value.is_a?(Numeric) && value.between?(0.0, 1.0)
|
|
92
|
+
|
|
93
|
+
value.to_f
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|