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
data/lib/wavify/cli.rb ADDED
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Wavify
6
+ # Minimal command-line interface for common Wavify workflows.
7
+ class CLI
8
+ COMMANDS = %w[info convert tone normalize trim chain render timeline formats doctor].freeze
9
+
10
+ def self.run(argv = ARGV, stdout: $stdout, stderr: $stderr)
11
+ new(argv, stdout: stdout, stderr: stderr).run
12
+ end
13
+
14
+ def initialize(argv, stdout:, stderr:)
15
+ @argv = argv.dup
16
+ @stdout = stdout
17
+ @stderr = stderr
18
+ end
19
+
20
+ def run
21
+ command = @argv.shift
22
+ return usage if command.nil? || %w[-h --help help].include?(command)
23
+ return version if command == "--version"
24
+
25
+ unless COMMANDS.include?(command)
26
+ @stderr.puts "wavify: unknown command #{command.inspect}"
27
+ return usage(status: 1, output: @stderr)
28
+ end
29
+
30
+ options = parse_options!(@argv)
31
+ return usage if options.delete(:help)
32
+ return version if options.delete(:version)
33
+
34
+ @options = options
35
+ send("run_#{command}")
36
+ raise InvalidParameterError, "unexpected arguments: #{@argv.join(' ')}" unless @argv.empty?
37
+
38
+ 0
39
+ rescue Wavify::Error, ArgumentError, SyntaxError => e
40
+ @stderr.puts "wavify: #{e.message}"
41
+ 1
42
+ rescue StandardError => e
43
+ @stderr.puts "wavify: unexpected error (#{e.class}): #{e.message}"
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ def run_info
50
+ path = require_argument!("input path")
51
+ metadata = Audio.metadata(path)
52
+ format = metadata.fetch(:format)
53
+ @stdout.puts "path: #{path}"
54
+ @stdout.puts "format: #{format.sample_rate}Hz #{format.channels}ch #{format.bit_depth}-bit #{format.sample_format}"
55
+ @stdout.puts "duration: #{metadata[:duration]}"
56
+ @stdout.puts "frames: #{metadata[:sample_frame_count]}"
57
+ Array(metadata[:warnings]).each { |warning| @stdout.puts "warning: #{warning}" }
58
+ end
59
+
60
+ def run_convert
61
+ input = require_argument!("input path")
62
+ output = require_argument!("output path")
63
+ audio = Audio.read(input)
64
+ target_format = converted_format(audio.format, @options)
65
+ target = target_format == audio.format ? audio : audio.convert(target_format)
66
+ target.write(output)
67
+ @stdout.puts "converted: #{input} -> #{output}"
68
+ end
69
+
70
+ def run_tone
71
+ output = require_argument!("output path")
72
+ format = base_format(@options)
73
+ audio = Audio.tone(
74
+ frequency: @options.fetch(:freq, 440.0),
75
+ duration: @options.fetch(:duration, 1.0),
76
+ waveform: @options.fetch(:waveform, :sine),
77
+ format: format
78
+ )
79
+ audio.write(output)
80
+ @stdout.puts "wrote: #{output}"
81
+ end
82
+
83
+ def run_normalize
84
+ input = require_argument!("input path")
85
+ output = require_argument!("output path")
86
+ Audio.read(input).normalize(target_db: @options.fetch(:target, -1.0)).write(output)
87
+ @stdout.puts "normalized: #{input} -> #{output}"
88
+ end
89
+
90
+ def run_trim
91
+ input = require_argument!("input path")
92
+ output = require_argument!("output path")
93
+ Audio.read(input).trim(threshold: @options.fetch(:threshold, 0.01)).write(output)
94
+ @stdout.puts "trimmed: #{input} -> #{output}"
95
+ end
96
+
97
+ def run_chain
98
+ input = require_argument!("input path")
99
+ output = require_argument!("output path")
100
+ audio = Audio.read(input)
101
+ audio = audio.gain(@options[:gain]) if @options.key?(:gain)
102
+ audio = audio.fade_in(@options[:fade_in]) if @options.key?(:fade_in)
103
+ audio = audio.fade_out(@options[:fade_out]) if @options.key?(:fade_out)
104
+ target_format = converted_format(audio.format, @options)
105
+ audio = audio.convert(target_format) if target_format != audio.format
106
+ audio.write(output)
107
+ @stdout.puts "processed: #{input} -> #{output}"
108
+ end
109
+
110
+ def run_render
111
+ input = require_argument!("song path")
112
+ output = require_argument!("output path")
113
+ song = load_song_definition(input, @options)
114
+ song.write(output, default_bars: @options.fetch(:bars, 1))
115
+ @stdout.puts "rendered: #{input} -> #{output}"
116
+ end
117
+
118
+ def run_timeline
119
+ input = require_argument!("song path")
120
+ song = load_song_definition(input, @options)
121
+ output = if @options[:json]
122
+ song.timeline_json(default_bars: @options.fetch(:bars, 1))
123
+ else
124
+ song.timeline_text(default_bars: @options.fetch(:bars, 1))
125
+ end
126
+ @stdout.puts output
127
+ end
128
+
129
+ def run_formats
130
+ @stdout.puts Codecs.supported_formats.join("\n")
131
+ end
132
+
133
+ def run_doctor
134
+ @stdout.puts "ruby: #{RUBY_VERSION}"
135
+ @stdout.puts "formats: #{Codecs.supported_formats.join(', ')}"
136
+ @stdout.puts "available formats: #{Codecs.available_formats.join(', ')}"
137
+ check_codec("ogg/vorbis", Codecs::OggVorbis)
138
+ end
139
+
140
+ def check_codec(name, codec)
141
+ if codec.available?
142
+ diagnostics = codec.respond_to?(:runtime_diagnostics) ? codec.runtime_diagnostics : nil
143
+ if diagnostics && !diagnostics[:compatible]
144
+ missing = diagnostics.fetch(:missing_native_methods).join(", ")
145
+ @stdout.puts "#{name}: incompatible native API (missing: #{missing})"
146
+ elsif diagnostics
147
+ versions = diagnostics.fetch(:versions).filter_map { |gem, version| "#{gem} #{version}" if version }.join(", ")
148
+ suffix = versions.empty? ? "" : " (#{versions})"
149
+ @stdout.puts "#{name}: ok#{suffix}"
150
+ else
151
+ @stdout.puts "#{name}: ok"
152
+ end
153
+ else
154
+ @stdout.puts "#{name}: missing optional gems (add ogg-ruby and vorbis to your Gemfile)"
155
+ end
156
+ end
157
+
158
+ def parse_options!(tokens)
159
+ options = {}
160
+ parser = OptionParser.new do |opts|
161
+ opts.on("--freq HZ", Float) { |value| options[:freq] = value }
162
+ opts.on("--duration SECONDS", Float) { |value| options[:duration] = value }
163
+ opts.on("--waveform NAME") { |value| options[:waveform] = value.to_sym }
164
+ opts.on("--sample-rate HZ", Integer) { |value| options[:sample_rate] = value }
165
+ opts.on("--channels COUNT", Integer) { |value| options[:channels] = value }
166
+ opts.on("--bit-depth BITS", Integer) { |value| options[:bit_depth] = value }
167
+ opts.on("--target DB", Float) { |value| options[:target] = value }
168
+ opts.on("--threshold LEVEL", Float) { |value| options[:threshold] = value }
169
+ opts.on("--gain DB", Float) { |value| options[:gain] = value }
170
+ opts.on("--fade-in SECONDS", Float) { |value| options[:fade_in] = value }
171
+ opts.on("--fade-out SECONDS", Float) { |value| options[:fade_out] = value }
172
+ opts.on("--tempo BPM", Float) { |value| options[:tempo] = value }
173
+ opts.on("--swing AMOUNT", Float) { |value| options[:swing] = value }
174
+ opts.on("--beats-per-bar COUNT", Integer) { |value| options[:beats_per_bar] = value }
175
+ opts.on("--bars COUNT", Integer) { |value| options[:bars] = value }
176
+ opts.on("--seed INTEGER", Integer) { |value| options[:seed] = value }
177
+ opts.on("--json") { options[:json] = true }
178
+ opts.on("-h", "--help") { options[:help] = true }
179
+ opts.on("--version") { options[:version] = true }
180
+ end
181
+ parser.parse!(tokens)
182
+ options
183
+ rescue OptionParser::ParseError => e
184
+ raise InvalidParameterError, e.message
185
+ end
186
+
187
+ def base_format(options)
188
+ converted_format(Core::Format::CD_QUALITY, options)
189
+ end
190
+
191
+ def converted_format(format, options)
192
+ format.with(
193
+ channels: options[:channels],
194
+ sample_rate: options[:sample_rate],
195
+ bit_depth: options[:bit_depth]
196
+ )
197
+ end
198
+
199
+ def require_argument!(name)
200
+ value = @argv.shift
201
+ raise InvalidParameterError, "missing #{name}" if value.nil? || value.empty?
202
+
203
+ value
204
+ end
205
+
206
+ def read_source_file(path)
207
+ File.read(path)
208
+ rescue Errno::ENOENT
209
+ raise InvalidParameterError, "song file not found: #{path}"
210
+ end
211
+
212
+ def load_song_definition(path, options)
213
+ source = read_source_file(path)
214
+ DSL.build_definition(
215
+ format: base_format(options),
216
+ tempo: options.fetch(:tempo, 120.0),
217
+ beats_per_bar: options.fetch(:beats_per_bar, 4),
218
+ swing: options.fetch(:swing, 0.5),
219
+ default_bars: options.fetch(:bars, 1),
220
+ random_seed: options.fetch(:seed) { Random.new_seed }
221
+ ) do
222
+ instance_eval(source, path, 1)
223
+ end
224
+ end
225
+
226
+ def version
227
+ @stdout.puts Wavify::VERSION
228
+ 0
229
+ end
230
+
231
+ def usage(status: 0, output: @stdout)
232
+ output.puts "usage: wavify <#{COMMANDS.join('|')}> [options]"
233
+ output.puts "warning: render/timeline song files are executed as trusted Ruby code"
234
+ status
235
+ end
236
+ end
237
+ end