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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require "fileutils"
5
+ require_relative "../lib/wavify"
6
+
7
+ module WavifyBenchmarks
8
+ module Helper
9
+ module_function
10
+
11
+ TMP_DIR = File.expand_path("../tmp/benchmarks", __dir__)
12
+
13
+ def int_env(name, default)
14
+ value = ENV.fetch(name, nil)
15
+ return default if value.nil? || value.empty?
16
+
17
+ Integer(value)
18
+ rescue ArgumentError
19
+ default
20
+ end
21
+
22
+ def float_env(name, default)
23
+ value = ENV.fetch(name, nil)
24
+ return default if value.nil? || value.empty?
25
+
26
+ Float(value)
27
+ rescue ArgumentError
28
+ default
29
+ end
30
+
31
+ def bool_env(name, default: false)
32
+ value = ENV.fetch(name, nil)
33
+ return default if value.nil?
34
+
35
+ %w[1 true yes on].include?(value.downcase)
36
+ end
37
+
38
+ def tmp_dir
39
+ FileUtils.mkdir_p(TMP_DIR)
40
+ TMP_DIR
41
+ end
42
+
43
+ def banner(title)
44
+ puts
45
+ puts "== #{title} =="
46
+ end
47
+
48
+ def measure(label)
49
+ result = nil
50
+ elapsed = Benchmark.realtime do
51
+ result = yield
52
+ end
53
+ puts format(" %<label>-32s %<elapsed>8.4fs", label: label, elapsed: elapsed)
54
+ [elapsed, result]
55
+ end
56
+
57
+ def rss_kb
58
+ value = `ps -o rss= -p #{Process.pid}`.to_s.strip
59
+ return nil if value.empty?
60
+
61
+ Integer(value)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ def file_size_mb(path)
67
+ return 0.0 unless File.file?(path)
68
+
69
+ File.size(path) / (1024.0 * 1024.0)
70
+ end
71
+
72
+ def float_stereo_format(sample_rate: 44_100)
73
+ Wavify::Core::Format.new(channels: 2, sample_rate: sample_rate, bit_depth: 32, sample_format: :float)
74
+ end
75
+
76
+ def pcm_stereo_format(sample_rate: 44_100)
77
+ Wavify::Core::Format.new(channels: 2, sample_rate: sample_rate, bit_depth: 16, sample_format: :pcm)
78
+ end
79
+
80
+ def demo_audio(duration_seconds:, format: float_stereo_format)
81
+ raise ArgumentError, "duration_seconds must be > 0" unless duration_seconds.is_a?(Numeric) && duration_seconds.positive?
82
+
83
+ lead = Wavify::Audio.tone(frequency: 220.0, duration: duration_seconds, waveform: :sawtooth, format: format).gain(-14)
84
+ harmony = Wavify::Audio.tone(frequency: 330.0, duration: duration_seconds, waveform: :triangle, format: format).gain(-18)
85
+ noise = Wavify::Audio.tone(frequency: 1.0, duration: duration_seconds, waveform: :white_noise, format: format).gain(-34)
86
+
87
+ Wavify::Audio.mix(lead, harmony, noise)
88
+ .apply(Wavify::Effects::Chorus.new(rate: 0.25, depth: 0.2, mix: 0.15))
89
+ .normalize(target_db: -3.0)
90
+ end
91
+
92
+ def print_config(config)
93
+ config.each do |key, value|
94
+ puts " #{key}: #{value}"
95
+ end
96
+ end
97
+
98
+ def maybe_cleanup(*paths)
99
+ return if bool_env("KEEP_BENCH_FILES", default: false)
100
+
101
+ paths.each do |path|
102
+ File.delete(path) if File.file?(path)
103
+ end
104
+ end
105
+
106
+ def gain_chunk_processor(db)
107
+ lambda do |chunk|
108
+ Wavify::Audio.new(chunk).gain(db).buffer
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "benchmark_helper"
5
+
6
+ helper = WavifyBenchmarks::Helper
7
+ iterations = helper.int_env("DSP_BENCH_ITERATIONS", 8)
8
+ duration_seconds = helper.float_env("DSP_BENCH_DURATION", 2.0)
9
+ format = helper.float_stereo_format
10
+ audio = helper.demo_audio(duration_seconds: duration_seconds, format: format)
11
+ buffer = audio.buffer
12
+
13
+ helper.banner("DSP effects benchmark")
14
+ helper.print_config(
15
+ iterations: iterations,
16
+ duration_seconds: duration_seconds,
17
+ sample_rate: buffer.format.sample_rate,
18
+ channels: buffer.format.channels,
19
+ frames: buffer.sample_frame_count
20
+ )
21
+
22
+ effects = {
23
+ delay: -> { Wavify::Effects::Delay.new(time: 0.18, feedback: 0.35, mix: 0.25) },
24
+ reverb: -> { Wavify::Effects::Reverb.new(room_size: 0.6, damping: 0.4, mix: 0.2) },
25
+ chorus: -> { Wavify::Effects::Chorus.new(rate: 0.8, depth: 0.35, mix: 0.3) },
26
+ distortion: -> { Wavify::Effects::Distortion.new(drive: 0.45, tone: 0.6, mix: 0.4) },
27
+ compressor: -> { Wavify::Effects::Compressor.new(threshold: -16, ratio: 3.0, attack: 0.005, release: 0.1) }
28
+ }.freeze
29
+
30
+ effects.each do |name, factory|
31
+ helper.measure("#{name} x#{iterations}") do
32
+ iterations.times do
33
+ effect = factory.call
34
+ effect.process(buffer)
35
+ end
36
+ end
37
+ end
38
+
39
+ helper.measure("audio chain x#{iterations}") do
40
+ iterations.times do
41
+ audio
42
+ .apply(Wavify::Effects::Compressor.new(threshold: -16, ratio: 2.5, attack: 0.005, release: 0.08))
43
+ .apply(Wavify::Effects::Chorus.new(rate: 0.7, depth: 0.2, mix: 0.2))
44
+ .apply(Wavify::Effects::Reverb.new(room_size: 0.4, damping: 0.5, mix: 0.15))
45
+ end
46
+ end
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "benchmark_helper"
5
+
6
+ helper = WavifyBenchmarks::Helper
7
+ iterations = helper.int_env("FLAC_BENCH_ITERATIONS", 4)
8
+ duration_seconds = helper.float_env("FLAC_BENCH_DURATION", 3.0)
9
+ chunk_size = helper.int_env("FLAC_BENCH_CHUNK", 4096)
10
+ format = helper.pcm_stereo_format
11
+
12
+ source_audio = helper.demo_audio(duration_seconds: duration_seconds, format: helper.float_stereo_format)
13
+ source_audio = source_audio.convert(format)
14
+
15
+ wav_source_path = File.join(helper.tmp_dir, "flac_bench_source.wav")
16
+ flac_path = File.join(helper.tmp_dir, "flac_bench_audio_write.flac")
17
+ flac_stream_path = File.join(helper.tmp_dir, "flac_bench_stream_write.flac")
18
+
19
+ helper.banner("FLAC encode/decode benchmark")
20
+ helper.print_config(
21
+ iterations: iterations,
22
+ duration_seconds: duration_seconds,
23
+ chunk_size: chunk_size,
24
+ sample_rate: format.sample_rate,
25
+ channels: format.channels,
26
+ bit_depth: format.bit_depth
27
+ )
28
+
29
+ helper.measure("prepare wav source") do
30
+ source_audio.write(wav_source_path, format: format)
31
+ end
32
+
33
+ helper.measure("audio.write(.flac) x#{iterations}") do
34
+ iterations.times do
35
+ source_audio.write(flac_path, format: format)
36
+ end
37
+ end
38
+
39
+ helper.measure("audio.read(.flac) x#{iterations}") do
40
+ iterations.times { Wavify::Audio.read(flac_path) }
41
+ end
42
+
43
+ helper.measure("stream wav->flac x#{iterations}") do
44
+ iterations.times do
45
+ Wavify::Codecs::Flac.stream_write(flac_stream_path, format: format) do |writer|
46
+ Wavify::Codecs::Wav.stream_read(wav_source_path, chunk_size: chunk_size) do |chunk|
47
+ writer.call(chunk)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ helper.measure("stream read flac x#{iterations}") do
54
+ iterations.times do
55
+ total_frames = 0
56
+ Wavify::Codecs::Flac.stream_read(flac_stream_path, chunk_size: chunk_size) do |chunk|
57
+ total_frames += chunk.sample_frame_count
58
+ end
59
+ total_frames
60
+ end
61
+ end
62
+
63
+ metadata = Wavify::Codecs::Flac.metadata(flac_path)
64
+ wav_size_mb = helper.file_size_mb(wav_source_path)
65
+ flac_size_mb = helper.file_size_mb(flac_path)
66
+ ratio = wav_size_mb.zero? ? 0.0 : (flac_size_mb / wav_size_mb)
67
+
68
+ puts " source frames: #{source_audio.sample_frame_count}"
69
+ puts " wav source size: #{wav_size_mb.round(2)} MB"
70
+ puts " flac write size: #{flac_size_mb.round(2)} MB"
71
+ puts " flac/wav ratio: #{ratio.round(3)}"
72
+ puts " flac block size: #{metadata[:min_block_size]}..#{metadata[:max_block_size]}"
73
+
74
+ helper.maybe_cleanup(wav_source_path, flac_path, flac_stream_path)
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "benchmark_helper"
5
+
6
+ helper = WavifyBenchmarks::Helper
7
+ duration_seconds = helper.float_env("STREAM_BENCH_DURATION", 30.0)
8
+ chunk_frames = helper.int_env("STREAM_BENCH_CHUNK", 4096)
9
+ format = helper.pcm_stereo_format
10
+ float_chunk_format = format.with(sample_format: :float, bit_depth: 32)
11
+ source_path = File.join(helper.tmp_dir, "streaming_source.wav")
12
+ output_path = File.join(helper.tmp_dir, "streaming_processed.wav")
13
+
14
+ def build_large_source(path, duration_seconds:, chunk_frames:, format:, float_chunk_format:)
15
+ total_frames = (duration_seconds * format.sample_rate).round
16
+ remaining = total_frames
17
+
18
+ tone = Wavify::DSP::Oscillator.new(waveform: :sine, frequency: 110.0, amplitude: 0.45)
19
+ noise = Wavify::DSP::Oscillator.new(waveform: :white_noise, frequency: 1.0, amplitude: 0.08)
20
+ tone_enum = tone.each_sample(format: float_chunk_format)
21
+ noise_enum = noise.each_sample(format: float_chunk_format)
22
+
23
+ Wavify::Codecs::Wav.stream_write(path, format: format) do |writer|
24
+ while remaining.positive?
25
+ frames = [remaining, chunk_frames].min
26
+ samples = Array.new(frames * float_chunk_format.channels)
27
+
28
+ frames.times do |frame_index|
29
+ value = tone_enum.next + noise_enum.next
30
+ base = frame_index * float_chunk_format.channels
31
+ float_chunk_format.channels.times do |channel_index|
32
+ samples[base + channel_index] = value.clamp(-1.0, 1.0)
33
+ end
34
+ end
35
+
36
+ writer.call(Wavify::Core::SampleBuffer.new(samples, float_chunk_format))
37
+ remaining -= frames
38
+ end
39
+ end
40
+
41
+ total_frames
42
+ end
43
+
44
+ helper.banner("Streaming memory benchmark")
45
+ helper.print_config(
46
+ duration_seconds: duration_seconds,
47
+ chunk_frames: chunk_frames,
48
+ sample_rate: format.sample_rate,
49
+ channels: format.channels
50
+ )
51
+
52
+ generated_frames = nil
53
+ helper.measure("generate streamed source") do
54
+ generated_frames = build_large_source(
55
+ source_path,
56
+ duration_seconds: duration_seconds,
57
+ chunk_frames: chunk_frames,
58
+ format: format,
59
+ float_chunk_format: float_chunk_format
60
+ )
61
+ end
62
+
63
+ rss_before = helper.rss_kb
64
+ rss_peak = rss_before
65
+ probe_counter = 0
66
+
67
+ probe = lambda do |chunk|
68
+ probe_counter += 1
69
+ if (probe_counter % 25).zero?
70
+ current_rss = helper.rss_kb
71
+ rss_peak = [rss_peak, current_rss].compact.max
72
+ end
73
+ chunk
74
+ end
75
+
76
+ helper.measure("stream process pipeline") do
77
+ Wavify::Audio.stream(source_path, chunk_size: chunk_frames)
78
+ .pipe(probe)
79
+ .pipe(Wavify::Effects::Compressor.new(threshold: -18, ratio: 3.0, attack: 0.003, release: 0.08))
80
+ .pipe(Wavify::Effects::Chorus.new(rate: 0.5, depth: 0.2, mix: 0.12))
81
+ .pipe(helper.gain_chunk_processor(-2.0))
82
+ .write_to(output_path, format: format)
83
+ end
84
+
85
+ rss_after = helper.rss_kb
86
+
87
+ puts " source frames: #{generated_frames}"
88
+ puts " source file size: #{helper.file_size_mb(source_path).round(2)} MB"
89
+ puts " output file size: #{helper.file_size_mb(output_path).round(2)} MB"
90
+ puts " rss before: #{rss_before || 'n/a'} KB"
91
+ puts " rss peak (sampled): #{rss_peak || 'n/a'} KB"
92
+ puts " rss after: #{rss_after || 'n/a'} KB"
93
+
94
+ helper.maybe_cleanup(source_path, output_path)
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "benchmark_helper"
5
+
6
+ helper = WavifyBenchmarks::Helper
7
+ iterations = helper.int_env("WAV_IO_ITERATIONS", 5)
8
+ duration_seconds = helper.float_env("WAV_IO_DURATION", 3.0)
9
+ chunk_size = helper.int_env("WAV_IO_CHUNK", 4096)
10
+
11
+ wavefile_loaded = begin
12
+ require "wavefile"
13
+ true
14
+ rescue LoadError
15
+ false
16
+ end
17
+
18
+ def wavefile_format_for(format)
19
+ channels = case format.channels
20
+ when 1 then :mono
21
+ when 2 then :stereo
22
+ else format.channels
23
+ end
24
+
25
+ sample_format = if format.sample_format == :pcm
26
+ :"pcm_#{format.bit_depth}"
27
+ elsif format.sample_format == :float && format.bit_depth == 32
28
+ :float
29
+ else
30
+ raise ArgumentError, "unsupported WaveFile benchmark format: #{format.sample_format}/#{format.bit_depth}"
31
+ end
32
+
33
+ WaveFile::Format.new(channels, sample_format, format.sample_rate)
34
+ end
35
+
36
+ def wavefile_buffer_for(audio)
37
+ format = audio.format
38
+ frame_samples = audio.buffer.samples.each_slice(format.channels).map(&:dup)
39
+ WaveFile::Buffer.new(frame_samples, wavefile_format_for(format))
40
+ end
41
+
42
+ helper.banner("WAV IO benchmark")
43
+ helper.print_config(
44
+ iterations: iterations,
45
+ duration_seconds: duration_seconds,
46
+ chunk_size: chunk_size,
47
+ wavefile_compare: wavefile_loaded
48
+ )
49
+
50
+ source_audio = helper.demo_audio(duration_seconds: duration_seconds, format: helper.float_stereo_format)
51
+ source_audio = source_audio.convert(helper.pcm_stereo_format)
52
+
53
+ source_path = File.join(helper.tmp_dir, "wav_io_source.wav")
54
+ processed_path = File.join(helper.tmp_dir, "wav_io_processed.wav")
55
+ wavefile_path = File.join(helper.tmp_dir, "wav_io_wavefile.wav")
56
+
57
+ wavify_write_elapsed, = helper.measure("write source x#{iterations}") do
58
+ iterations.times { source_audio.write(source_path) }
59
+ end
60
+
61
+ wavify_read_elapsed, = helper.measure("read source x#{iterations}") do
62
+ iterations.times { Wavify::Audio.read(source_path) }
63
+ end
64
+
65
+ helper.measure("stream read+write x#{iterations}") do
66
+ iterations.times do
67
+ Wavify::Audio.stream(source_path, chunk_size: chunk_size)
68
+ .pipe(helper.gain_chunk_processor(-3.0))
69
+ .pipe(Wavify::Effects::Compressor.new(threshold: -16, ratio: 2.0, attack: 0.005, release: 0.05))
70
+ .write_to(processed_path, format: helper.pcm_stereo_format)
71
+ end
72
+ end
73
+
74
+ puts " source file size: #{helper.file_size_mb(source_path).round(2)} MB"
75
+ puts " processed file size: #{helper.file_size_mb(processed_path).round(2)} MB"
76
+
77
+ if wavefile_loaded
78
+ wavefile_format = wavefile_format_for(source_audio.format)
79
+ wavefile_buffer = wavefile_buffer_for(source_audio)
80
+
81
+ wavefile_write_elapsed, = helper.measure("WaveFile write x#{iterations}") do
82
+ iterations.times do
83
+ WaveFile::Writer.new(wavefile_path, wavefile_format) do |writer|
84
+ writer.write(wavefile_buffer)
85
+ end
86
+ end
87
+ end
88
+
89
+ wavefile_read_elapsed, = helper.measure("WaveFile read x#{iterations}") do
90
+ iterations.times do
91
+ WaveFile::Reader.new(wavefile_path) do |reader|
92
+ reader.each_buffer(chunk_size) { |_buffer| }
93
+ end
94
+ end
95
+ end
96
+
97
+ if wavify_write_elapsed.positive?
98
+ puts format(" Wavify/WaveFile write speed: %7.2f%%",
99
+ (wavefile_write_elapsed / wavify_write_elapsed) * 100.0)
100
+ end
101
+ if wavify_read_elapsed.positive?
102
+ puts format(" Wavify/WaveFile read speed: %7.2f%%",
103
+ (wavefile_read_elapsed / wavify_read_elapsed) * 100.0)
104
+ end
105
+ else
106
+ puts " WaveFile comparison skipped (optional gem not installed)"
107
+ puts " Install with: gem install wavefile"
108
+ end
109
+
110
+ helper.maybe_cleanup(source_path, processed_path, wavefile_path)
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "../lib/wavify"
6
+
7
+ OUTPUT_DIR = File.expand_path("../tmp/examples", __dir__)
8
+
9
+ def float_format
10
+ Wavify::Core::Format.new(channels: 2, sample_rate: 44_100, bit_depth: 32, sample_format: :float)
11
+ end
12
+
13
+ def build_demo_recording
14
+ format = float_format
15
+ lead = Wavify::Audio.tone(frequency: 220.0, duration: 1.0, waveform: :sine, format: format).gain(-12)
16
+ harmony = Wavify::Audio.tone(frequency: 330.0, duration: 1.0, waveform: :triangle, format: format).gain(-16)
17
+ body = Wavify::Audio.mix(lead, harmony)
18
+
19
+ noise = Wavify::Audio.tone(frequency: 1.0, duration: body.duration.total_seconds, waveform: :white_noise, format: format).gain(-30)
20
+ signal = Wavify::Audio.mix(body, noise)
21
+
22
+ head = Wavify::Audio.silence(0.15, format: format)
23
+ tail = Wavify::Audio.silence(0.2, format: format)
24
+ Wavify::Audio.new(head.buffer + signal.buffer + tail.buffer)
25
+ end
26
+
27
+ def process(audio)
28
+ audio
29
+ .trim(threshold: 0.02)
30
+ .fade_in(0.01)
31
+ .fade_out(0.08)
32
+ .apply(Wavify::Effects::Compressor.new(threshold: -18, ratio: 3.5, attack: 0.005, release: 0.08))
33
+ .apply(Wavify::Effects::Distortion.new(drive: 0.2, tone: 0.65, mix: 0.12))
34
+ .normalize(target_db: -1.0)
35
+ end
36
+
37
+ def process_file(input_path, output_path)
38
+ source = Wavify::Audio.read(input_path)
39
+ processed = process(source)
40
+ processed.convert(Wavify::Core::Format::CD_QUALITY).write(output_path)
41
+
42
+ puts "Processed #{input_path} -> #{output_path}"
43
+ puts " source duration: #{source.duration}"
44
+ puts " processed duration: #{processed.duration}"
45
+ puts " source peak: #{source.peak_amplitude.round(4)}"
46
+ puts " output peak: #{processed.peak_amplitude.round(4)}"
47
+ end
48
+
49
+ def run_demo
50
+ FileUtils.mkdir_p(OUTPUT_DIR)
51
+ source_path = File.join(OUTPUT_DIR, "audio_processing_source.wav")
52
+ output_path = File.join(OUTPUT_DIR, "audio_processing_output.wav")
53
+
54
+ source = build_demo_recording
55
+ source.convert(Wavify::Core::Format::CD_QUALITY).write(source_path)
56
+ process(source).convert(Wavify::Core::Format::CD_QUALITY).write(output_path)
57
+
58
+ puts "Generated demo processing files:"
59
+ puts " #{source_path}"
60
+ puts " #{output_path}"
61
+ end
62
+
63
+ if ARGV.include?("-h") || ARGV.include?("--help")
64
+ puts "Usage: ruby examples/audio_processing.rb [INPUT_PATH OUTPUT_PATH]"
65
+ puts "When no args are given, a self-contained demo is written to tmp/examples/."
66
+ exit(0)
67
+ end
68
+
69
+ if ARGV.length >= 2
70
+ process_file(ARGV[0], ARGV[1])
71
+ else
72
+ run_demo
73
+ end
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "../lib/wavify"
6
+
7
+ OUTPUT_DIR = File.expand_path("../tmp/examples", __dir__)
8
+ OUTPUT_PATH = File.join(OUTPUT_DIR, "cinematic_transition.wav")
9
+
10
+ def render_format
11
+ Wavify::Core::Format.new(channels: 2, sample_rate: 48_000, bit_depth: 32, sample_format: :float)
12
+ end
13
+
14
+ def place(audio, at:, total_length:, format:)
15
+ head = Wavify::Audio.silence(at, format: format)
16
+ clip = Wavify::Audio.new(head.buffer + audio.buffer)
17
+ remaining = total_length - clip.duration.total_seconds
18
+ return clip if remaining <= 0.0
19
+
20
+ Wavify::Audio.new(clip.buffer + Wavify::Audio.silence(remaining, format: format).buffer)
21
+ end
22
+
23
+ def drone_layer(frequency, duration, format)
24
+ base = Wavify::Audio.tone(frequency: frequency, duration: duration, waveform: :triangle, format: format)
25
+ .gain(-22)
26
+ .fade_in(1.2)
27
+ .fade_out(1.5)
28
+ shimmer = Wavify::Audio.tone(frequency: frequency * 2.0, duration: duration, waveform: :sine, format: format)
29
+ .gain(-30)
30
+ .fade_in(1.5)
31
+ .fade_out(1.2)
32
+ Wavify::Audio.mix(base, shimmer)
33
+ end
34
+
35
+ def riser(duration, format)
36
+ steps = [220.0, 330.0, 440.0, 660.0, 880.0]
37
+ segment = duration / steps.length
38
+ layers = steps.map do |frequency|
39
+ Wavify::Audio.tone(frequency: frequency, duration: segment, waveform: :sawtooth, format: format)
40
+ .gain(-24)
41
+ .fade_in(0.03)
42
+ .fade_out(0.04)
43
+ end
44
+
45
+ combined = layers.reduce { |memo, audio| Wavify::Audio.new(memo.buffer + audio.buffer) }
46
+ noise = Wavify::Audio.tone(frequency: 1.0, duration: duration, waveform: :white_noise, format: format)
47
+ .gain(-33)
48
+ .fade_in(duration * 0.7)
49
+ .fade_out(duration * 0.1)
50
+ .apply(Wavify::DSP::Filter.highpass(cutoff: 2_500.0))
51
+ Wavify::Audio.mix(combined, noise).apply(Wavify::Effects::Chorus.new(rate: 0.5, depth: 0.35, mix: 0.25))
52
+ end
53
+
54
+ def impact(format)
55
+ low = Wavify::Audio.tone(frequency: 55.0, duration: 0.5, waveform: :sine, format: format)
56
+ .gain(-10)
57
+ .fade_out(0.45)
58
+ noise = Wavify::Audio.tone(frequency: 1.0, duration: 0.38, waveform: :white_noise, format: format)
59
+ .gain(-17)
60
+ .apply(Wavify::DSP::Filter.bandpass(center: 1_600.0, bandwidth: 900.0))
61
+ .fade_out(0.32)
62
+ click = Wavify::Audio.tone(frequency: 2_000.0, duration: 0.02, waveform: :triangle, format: format)
63
+ .gain(-20)
64
+ .fade_out(0.015)
65
+
66
+ Wavify::Audio.mix(low, noise, click)
67
+ .apply(Wavify::Effects::Distortion.new(drive: 0.18, tone: 0.6, mix: 0.16))
68
+ .normalize(target_db: -3.5)
69
+ end
70
+
71
+ def reverse_tail(format)
72
+ source = Wavify::Audio.tone(frequency: 480.0, duration: 1.1, waveform: :triangle, format: format)
73
+ .gain(-28)
74
+ .apply(Wavify::Effects::Reverb.new(room_size: 0.72, damping: 0.48, mix: 0.44))
75
+ .fade_out(1.0)
76
+ source.reverse.fade_in(0.6).fade_out(0.2)
77
+ end
78
+
79
+ def master(audio)
80
+ audio
81
+ .apply(Wavify::Effects::Compressor.new(threshold: -16, ratio: 3.2, attack: 0.004, release: 0.1))
82
+ .apply(Wavify::Effects::Reverb.new(room_size: 0.45, damping: 0.5, mix: 0.18))
83
+ .normalize(target_db: -1.0)
84
+ .fade_in(0.08)
85
+ .fade_out(1.2)
86
+ end
87
+
88
+ def build_scene
89
+ format = render_format
90
+ total_length = 12.0
91
+
92
+ base_drone = Wavify::Audio.mix(
93
+ drone_layer(110.0, total_length, format).pan(-0.15),
94
+ drone_layer(146.83, total_length, format).pan(0.14)
95
+ )
96
+ pulse = Wavify::Audio.tone(frequency: 2.0, duration: 3.2, waveform: :sine, format: format)
97
+ .apply(Wavify::DSP::Filter.lowpass(cutoff: 180.0))
98
+ .gain(-31)
99
+ .fade_in(0.4)
100
+ .fade_out(0.5)
101
+ build = place(riser(2.8, format), at: 6.2, total_length: total_length, format: format)
102
+ hit = place(impact(format), at: 9.05, total_length: total_length, format: format)
103
+ tail = place(reverse_tail(format), at: 8.15, total_length: total_length, format: format)
104
+ pulse_layer = place(pulse, at: 4.9, total_length: total_length, format: format)
105
+
106
+ Wavify::Audio.mix(base_drone, pulse_layer, build, tail, hit)
107
+ end
108
+
109
+ FileUtils.mkdir_p(OUTPUT_DIR)
110
+
111
+ raw = build_scene
112
+ final = master(raw).convert(Wavify::Core::Format::CD_QUALITY)
113
+ final.write(OUTPUT_PATH)
114
+
115
+ puts "Wrote #{OUTPUT_PATH}"
116
+ puts " duration: #{final.duration}"
117
+ puts " peak: #{final.peak_amplitude.round(4)}"
118
+ puts " rms: #{final.rms_amplitude.round(4)}"