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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Sequencer
5
+ # Note sequence parser for note/rest/MIDI token notation.
6
+ class NoteSequence
7
+ include Enumerable
8
+
9
+ # Note-name to semitone offset lookup table.
10
+ NOTE_OFFSETS = {
11
+ "C" => 0,
12
+ "C#" => 1,
13
+ "DB" => 1,
14
+ "D" => 2,
15
+ "D#" => 3,
16
+ "EB" => 3,
17
+ "E" => 4,
18
+ "F" => 5,
19
+ "F#" => 6,
20
+ "GB" => 6,
21
+ "G" => 7,
22
+ "G#" => 8,
23
+ "AB" => 8,
24
+ "A" => 9,
25
+ "A#" => 10,
26
+ "BB" => 10,
27
+ "B" => 11
28
+ }.freeze
29
+
30
+ # Parsed note event (`midi_note` is `nil` for rests).
31
+ Event = Struct.new(:index, :token, :midi_note, keyword_init: true) do
32
+ def rest?
33
+ midi_note.nil?
34
+ end
35
+ end
36
+
37
+ attr_reader :default_octave, :events, :notation
38
+
39
+ def initialize(notation, default_octave: 4)
40
+ @notation = notation
41
+ @default_octave = validate_default_octave!(default_octave)
42
+ @events = parse_events(notation).freeze
43
+ end
44
+
45
+ # Enumerates parsed events.
46
+ #
47
+ # @yield [event]
48
+ # @yieldparam event [Event]
49
+ # @return [Enumerator]
50
+ def each(&)
51
+ return enum_for(:each) unless block_given?
52
+
53
+ @events.each(&)
54
+ end
55
+
56
+ # Returns an event at the given index.
57
+ #
58
+ # @param index [Integer]
59
+ # @return [Event, nil]
60
+ def [](index)
61
+ @events[index]
62
+ end
63
+
64
+ # @return [Integer] number of parsed events
65
+ def length
66
+ @events.length
67
+ end
68
+
69
+ alias size length
70
+
71
+ # @return [Array<Integer, nil>] MIDI notes preserving rests as nil
72
+ def midi_notes
73
+ @events.map(&:midi_note)
74
+ end
75
+
76
+ # @return [Array<Event>] events excluding rests
77
+ def note_events
78
+ @events.reject(&:rest?)
79
+ end
80
+
81
+ private
82
+
83
+ def validate_default_octave!(value)
84
+ raise InvalidNoteError, "default_octave must be an Integer" unless value.is_a?(Integer)
85
+
86
+ value
87
+ end
88
+
89
+ def parse_events(notation)
90
+ raise InvalidNoteError, "note sequence notation must be String" unless notation.is_a?(String)
91
+
92
+ tokens = notation.split(/\s+/).reject(&:empty?)
93
+ raise InvalidNoteError, "note sequence notation must not be empty" if tokens.empty?
94
+
95
+ tokens.each_with_index.map do |token, index|
96
+ Event.new(index: index, token: token, midi_note: parse_token(token))
97
+ end
98
+ end
99
+
100
+ def parse_token(token)
101
+ return nil if token == "."
102
+
103
+ return parse_midi_number(token) if token.match?(/\A-?\d+\z/)
104
+
105
+ parse_note_name(token)
106
+ end
107
+
108
+ def parse_midi_number(token)
109
+ midi = token.to_i
110
+ raise InvalidNoteError, "MIDI note out of range (0..127): #{token}" unless midi.between?(0, 127)
111
+
112
+ midi
113
+ end
114
+
115
+ def parse_note_name(token)
116
+ match = token.match(/\A([A-Ga-g])([#b]?)(-?\d+)?\z/)
117
+ raise InvalidNoteError, "invalid note token: #{token.inspect}" unless match
118
+
119
+ note_name = "#{match[1].upcase}#{match[2]}".upcase
120
+ octave = match[3] ? match[3].to_i : @default_octave
121
+
122
+ semitone = NOTE_OFFSETS[note_name]
123
+ raise InvalidNoteError, "unsupported note token: #{token.inspect}" unless semitone
124
+
125
+ midi = ((octave + 1) * 12) + semitone
126
+ raise InvalidNoteError, "note out of MIDI range: #{token.inspect}" unless midi.between?(0, 127)
127
+
128
+ midi
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Sequencer
5
+ # Step-pattern parser (`x`, `X`, `-`, `.`) for trigger sequencing.
6
+ class Pattern
7
+ include Enumerable
8
+
9
+ # Parsed pattern step value object.
10
+ Step = Struct.new(:index, :trigger, :accent, :symbol, :velocity, keyword_init: true) do
11
+ def rest?
12
+ !trigger
13
+ end
14
+
15
+ def trigger?
16
+ trigger
17
+ end
18
+
19
+ def accent?
20
+ accent
21
+ end
22
+ end
23
+
24
+ attr_reader :resolution, :steps, :notation
25
+
26
+ def initialize(notation, resolution: 16)
27
+ @notation = notation
28
+ @resolution = validate_resolution!(resolution)
29
+ @steps = parse_steps(notation).freeze
30
+ end
31
+
32
+ # Enumerates parsed steps.
33
+ #
34
+ # @yield [step]
35
+ # @yieldparam step [Step]
36
+ # @return [Enumerator]
37
+ def each(&)
38
+ return enum_for(:each) unless block_given?
39
+
40
+ @steps.each(&)
41
+ end
42
+
43
+ # Returns a step at the given index.
44
+ #
45
+ # @param index [Integer]
46
+ # @return [Step, nil]
47
+ def [](index)
48
+ @steps[index]
49
+ end
50
+
51
+ # @return [Integer] number of steps
52
+ def length
53
+ @steps.length
54
+ end
55
+
56
+ alias size length
57
+
58
+ # @return [Array<Integer>] indices for trigger steps
59
+ def trigger_indices
60
+ @steps.select(&:trigger?).map(&:index)
61
+ end
62
+
63
+ # @return [Array<Integer>] indices for accented trigger steps
64
+ def accented_indices
65
+ @steps.select(&:accent?).map(&:index)
66
+ end
67
+
68
+ # @return [Array<Step>] copy of parsed steps
69
+ def to_a
70
+ @steps.dup
71
+ end
72
+
73
+ private
74
+
75
+ def validate_resolution!(value)
76
+ raise InvalidPatternError, "resolution must be a positive Integer" unless value.is_a?(Integer) && value.positive?
77
+
78
+ value
79
+ end
80
+
81
+ def parse_steps(notation)
82
+ raise InvalidPatternError, "pattern notation must be String" unless notation.is_a?(String)
83
+
84
+ chars = notation.each_char.reject { |char| char =~ /\s/ || char == "|" }
85
+ raise InvalidPatternError, "pattern notation must not be empty" if chars.empty?
86
+
87
+ chars.each_with_index.map do |char, index|
88
+ case char
89
+ when "x"
90
+ Step.new(index: index, trigger: true, accent: false, symbol: char, velocity: 0.8)
91
+ when "X"
92
+ Step.new(index: index, trigger: true, accent: true, symbol: char, velocity: 1.0)
93
+ when "-", "."
94
+ Step.new(index: index, trigger: false, accent: false, symbol: char, velocity: 0.0)
95
+ else
96
+ raise InvalidPatternError, "invalid pattern symbol #{char.inspect} at step #{index}"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ module Sequencer
5
+ # Immutable sequencer track definition consumed by {Engine}.
6
+ class Track
7
+ # Chord suffix to semitone interval mapping.
8
+ CHORD_INTERVALS = {
9
+ "" => [0, 4, 7],
10
+ "M" => [0, 4, 7],
11
+ "MAJ" => [0, 4, 7],
12
+ "MAJ7" => [0, 4, 7, 11],
13
+ "7" => [0, 4, 7, 10],
14
+ "M7" => [0, 3, 7, 10],
15
+ "MIN" => [0, 3, 7],
16
+ "MIN7" => [0, 3, 7, 10],
17
+ "MIN9" => [0, 3, 7, 10, 14],
18
+ "MINOR" => [0, 3, 7],
19
+ "M7B5" => [0, 3, 6, 10],
20
+ "M7-5" => [0, 3, 6, 10],
21
+ "DIM" => [0, 3, 6],
22
+ "DIM7" => [0, 3, 6, 9],
23
+ "AUG" => [0, 4, 8],
24
+ "SUS2" => [0, 2, 7],
25
+ "SUS4" => [0, 5, 7],
26
+ "MAJ9" => [0, 4, 7, 11, 14]
27
+ }.merge(
28
+ "m" => [0, 3, 7],
29
+ "m7" => [0, 3, 7, 10],
30
+ "m9" => [0, 3, 7, 10, 14],
31
+ "maj7" => [0, 4, 7, 11],
32
+ "maj9" => [0, 4, 7, 11, 14],
33
+ "sus2" => [0, 2, 7],
34
+ "sus4" => [0, 5, 7],
35
+ "dim" => [0, 3, 6],
36
+ "dim7" => [0, 3, 6, 9],
37
+ "aug" => [0, 4, 8]
38
+ ).freeze
39
+
40
+ attr_reader :name, :pattern, :note_sequence, :chord_progression, :waveform, :gain_db, :pan_position,
41
+ :pattern_resolution, :note_resolution, :default_octave, :envelope, :effects
42
+
43
+ def initialize(name, **options)
44
+ @name = validate_name!(name)
45
+ pattern_resolution = options.fetch(:pattern_resolution, 16)
46
+ note_resolution = options.fetch(:note_resolution, 8)
47
+ default_octave = options.fetch(:default_octave, 4)
48
+
49
+ @pattern_resolution = validate_resolution!(pattern_resolution, :pattern_resolution)
50
+ @note_resolution = validate_resolution!(note_resolution, :note_resolution)
51
+ @default_octave = validate_default_octave!(default_octave)
52
+ @waveform = options.fetch(:waveform, :sine).to_sym
53
+ @gain_db = validate_numeric!(options.fetch(:gain_db, 0.0), :gain_db).to_f
54
+ @pan_position = validate_pan!(options.fetch(:pan_position, 0.0))
55
+ @envelope = validate_envelope!(options[:envelope])
56
+ @effects = validate_effects!(options.fetch(:effects, []))
57
+
58
+ @pattern = coerce_pattern(options[:pattern])
59
+ @note_sequence = coerce_note_sequence(options[:note_sequence])
60
+ @chord_progression = coerce_chord_progression(options[:chord_progression])
61
+ end
62
+
63
+ # Returns a copy with a new pattern.
64
+ #
65
+ # @param pattern [Pattern, String]
66
+ # @return [Track]
67
+ def with_pattern(pattern)
68
+ copy(pattern: pattern)
69
+ end
70
+
71
+ # Returns a copy with a new note sequence.
72
+ #
73
+ # @param notes [NoteSequence, String]
74
+ # @param default_octave [Integer]
75
+ # @return [Track]
76
+ def with_notes(notes, default_octave: @default_octave)
77
+ copy(note_sequence: notes, default_octave: default_octave)
78
+ end
79
+
80
+ # Returns a copy with a new chord progression.
81
+ #
82
+ # @param chords [String, Array<String>]
83
+ # @param default_octave [Integer]
84
+ # @return [Track]
85
+ def with_chords(chords, default_octave: @default_octave)
86
+ copy(chord_progression: chords, default_octave: default_octave)
87
+ end
88
+
89
+ # Returns a copy with a different oscillator waveform.
90
+ #
91
+ # @param waveform [Symbol, String]
92
+ # @return [Track]
93
+ def with_synth(waveform)
94
+ copy(waveform: waveform)
95
+ end
96
+
97
+ # Returns a copy with updated gain in dB.
98
+ #
99
+ # @param db [Numeric]
100
+ # @return [Track]
101
+ def with_gain(db)
102
+ copy(gain_db: db)
103
+ end
104
+
105
+ # Returns a copy with updated pan position.
106
+ #
107
+ # @param position [Numeric]
108
+ # @return [Track]
109
+ def with_pan(position)
110
+ copy(pan_position: position)
111
+ end
112
+
113
+ # Returns a copy with an envelope object.
114
+ #
115
+ # @param envelope [Wavify::DSP::Envelope, nil]
116
+ # @return [Track]
117
+ def with_envelope(envelope)
118
+ copy(envelope: envelope)
119
+ end
120
+
121
+ # Returns a copy with effect processors.
122
+ #
123
+ # @param effects [Array<Object>]
124
+ # @return [Track]
125
+ def with_effects(effects)
126
+ copy(effects: effects)
127
+ end
128
+
129
+ def event_sources?
130
+ pattern? || notes? || chords?
131
+ end
132
+
133
+ def pattern?
134
+ !@pattern.nil?
135
+ end
136
+
137
+ def notes?
138
+ !@note_sequence.nil?
139
+ end
140
+
141
+ def chords?
142
+ !@chord_progression.nil? && !@chord_progression.empty?
143
+ end
144
+
145
+ def effects?
146
+ !@effects.empty?
147
+ end
148
+
149
+ # Copy constructor used by immutable builder helpers.
150
+ #
151
+ # @return [Track]
152
+ def copy(**overrides)
153
+ self.class.new(
154
+ overrides.fetch(:name, @name),
155
+ pattern: overrides.fetch(:pattern, @pattern),
156
+ note_sequence: overrides.fetch(:note_sequence, @note_sequence),
157
+ chord_progression: overrides.fetch(:chord_progression, @chord_progression),
158
+ waveform: overrides.fetch(:waveform, @waveform),
159
+ gain_db: overrides.fetch(:gain_db, @gain_db),
160
+ pan_position: overrides.fetch(:pan_position, @pan_position),
161
+ pattern_resolution: overrides.fetch(:pattern_resolution, @pattern_resolution),
162
+ note_resolution: overrides.fetch(:note_resolution, @note_resolution),
163
+ default_octave: overrides.fetch(:default_octave, @default_octave),
164
+ envelope: overrides.fetch(:envelope, @envelope),
165
+ effects: overrides.fetch(:effects, @effects)
166
+ )
167
+ end
168
+
169
+ # Parses chord notation using this track's default octave.
170
+ #
171
+ # @param chords [String, Array<String>]
172
+ # @return [Array<Hash>]
173
+ def parse_chords(chords)
174
+ self.class.parse_chords(chords, default_octave: @default_octave)
175
+ end
176
+
177
+ def self.parse_chords(chords, default_octave: 4)
178
+ tokens = case chords
179
+ when String
180
+ chords.split(/\s+/)
181
+ when Array
182
+ chords.map(&:to_s)
183
+ else
184
+ raise InvalidNoteError, "chords must be String or Array"
185
+ end.reject(&:empty?)
186
+
187
+ raise InvalidNoteError, "chords must not be empty" if tokens.empty?
188
+
189
+ tokens.map { |token| parse_chord_token(token, default_octave: default_octave) }
190
+ end
191
+
192
+ def self.parse_chord_token(token, default_octave:)
193
+ match = token.match(/\A([A-Ga-g])([#b]?)(.*)\z/)
194
+ raise InvalidNoteError, "invalid chord token: #{token.inspect}" unless match
195
+
196
+ root_name = "#{match[1].upcase}#{match[2]}"
197
+ suffix = match[3].to_s
198
+ suffix_key = normalize_chord_suffix(suffix)
199
+ intervals = CHORD_INTERVALS[suffix_key] || CHORD_INTERVALS[suffix]
200
+ raise InvalidNoteError, "unsupported chord quality: #{suffix.inspect}" unless intervals
201
+
202
+ root_midi = NoteSequence.new("#{root_name}#{default_octave}", default_octave: default_octave).midi_notes.first
203
+ {
204
+ token: token,
205
+ root_midi: root_midi,
206
+ midi_notes: intervals.map { |interval| root_midi + interval }
207
+ }
208
+ end
209
+
210
+ def self.normalize_chord_suffix(suffix)
211
+ value = suffix.to_s
212
+ return "" if value.empty?
213
+
214
+ if value.start_with?("m") && !value.start_with?("maj")
215
+ "m#{value[1..]}"
216
+ else
217
+ value.downcase
218
+ end
219
+ end
220
+
221
+ private_class_method :normalize_chord_suffix
222
+
223
+ private
224
+
225
+ def validate_name!(name)
226
+ value = name.to_sym
227
+ raise SequencerError, "track name must not be empty" if value.to_s.empty?
228
+
229
+ value
230
+ rescue NoMethodError
231
+ raise SequencerError, "track name must be Symbol/String: #{name.inspect}"
232
+ end
233
+
234
+ def validate_resolution!(value, name)
235
+ raise SequencerError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
236
+
237
+ value
238
+ end
239
+
240
+ def validate_default_octave!(value)
241
+ raise SequencerError, "default_octave must be an Integer" unless value.is_a?(Integer)
242
+
243
+ value
244
+ end
245
+
246
+ def validate_numeric!(value, name)
247
+ raise SequencerError, "#{name} must be Numeric" unless value.is_a?(Numeric)
248
+
249
+ value
250
+ end
251
+
252
+ def validate_pan!(value)
253
+ raise SequencerError, "pan_position must be Numeric in -1.0..1.0" unless value.is_a?(Numeric) && value.between?(-1.0, 1.0)
254
+
255
+ value.to_f
256
+ end
257
+
258
+ def validate_envelope!(value)
259
+ return nil if value.nil?
260
+ raise SequencerError, "envelope must be a Wavify::DSP::Envelope" unless value.is_a?(Wavify::DSP::Envelope)
261
+
262
+ value
263
+ end
264
+
265
+ def validate_effects!(value)
266
+ effects = Array(value)
267
+ unless effects.all? { |effect| effect.respond_to?(:process) || effect.respond_to?(:apply) || effect.respond_to?(:call) }
268
+ raise SequencerError, "effects must respond to :process, :apply, or :call"
269
+ end
270
+
271
+ effects.freeze
272
+ end
273
+
274
+ def coerce_pattern(pattern)
275
+ return nil if pattern.nil?
276
+ return pattern if pattern.is_a?(Pattern)
277
+
278
+ Pattern.new(pattern, resolution: @pattern_resolution)
279
+ end
280
+
281
+ def coerce_note_sequence(note_sequence)
282
+ return nil if note_sequence.nil?
283
+ return note_sequence if note_sequence.is_a?(NoteSequence)
284
+
285
+ NoteSequence.new(note_sequence, default_octave: @default_octave)
286
+ end
287
+
288
+ def coerce_chord_progression(chord_progression)
289
+ return nil if chord_progression.nil?
290
+ if chord_progression.is_a?(Array) && chord_progression.all? { |item| item.is_a?(Hash) && item[:midi_notes] }
291
+ return chord_progression
292
+ end
293
+
294
+ self.class.parse_chords(chord_progression, default_octave: @default_octave)
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sequencer/pattern"
4
+ require_relative "sequencer/note_sequence"
5
+ require_relative "sequencer/track"
6
+ require_relative "sequencer/engine"
7
+
8
+ module Wavify
9
+ # Sequencer primitives for pattern/note/chord scheduling and rendering.
10
+ module Sequencer
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavify
4
+ # Gem version string (Semantic Versioning).
5
+ VERSION = "0.1.0"
6
+ end
data/lib/wavify.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wavify/version"
4
+ require_relative "wavify/errors"
5
+ require_relative "wavify/core/format"
6
+ require_relative "wavify/core/duration"
7
+ require_relative "wavify/core/sample_buffer"
8
+ require_relative "wavify/core/stream"
9
+ require_relative "wavify/codecs/base"
10
+ require_relative "wavify/codecs/raw"
11
+ require_relative "wavify/codecs/wav"
12
+ require_relative "wavify/codecs/flac"
13
+ require_relative "wavify/codecs/ogg_vorbis"
14
+ require_relative "wavify/codecs/aiff"
15
+ require_relative "wavify/codecs/registry"
16
+ require_relative "wavify/dsp/oscillator"
17
+ require_relative "wavify/dsp/envelope"
18
+ require_relative "wavify/dsp/filter"
19
+ require_relative "wavify/dsp/effects"
20
+ require_relative "wavify/sequencer"
21
+ require_relative "wavify/audio"
22
+ require_relative "wavify/dsl"
23
+
24
+ ##
25
+ # Wavify is a pure Ruby audio processing toolkit with immutable transforms,
26
+ # multiple codecs, DSP primitives, and a small sequencing DSL.
27
+ module Wavify
28
+ end
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "yaml"
6
+ require "tempfile"
7
+
8
+ require_relative "../lib/wavify"
9
+
10
+ module Wavify
11
+ module Tools
12
+ class FixtureWriter
13
+ def initialize(yaml_dir:, audio_dir:)
14
+ @yaml_dir = yaml_dir
15
+ @audio_dir = audio_dir
16
+ end
17
+
18
+ def run
19
+ FileUtils.mkdir_p(@audio_dir)
20
+ yaml_files.each { |path| write_file(path) }
21
+ end
22
+
23
+ private
24
+
25
+ def yaml_files
26
+ Dir.glob(File.join(@yaml_dir, "*.yml")).sort
27
+ end
28
+
29
+ def write_file(path)
30
+ yaml = YAML.safe_load_file(path)
31
+ fixtures = yaml.fetch("fixtures")
32
+ fixtures.each { |fixture| write_fixture(fixture) }
33
+ end
34
+
35
+ def write_fixture(fixture)
36
+ name = fixture.fetch("name")
37
+ kind = fixture.fetch("kind", "valid")
38
+ output_path = File.join(@audio_dir, name)
39
+
40
+ case kind
41
+ when "valid"
42
+ write_valid_fixture(output_path, fixture)
43
+ when "invalid_no_riff"
44
+ File.binwrite(output_path, "BROKEN")
45
+ when "invalid_truncated"
46
+ write_truncated_fixture(output_path, fixture)
47
+ else
48
+ raise Wavify::InvalidParameterError, "unsupported fixture kind: #{kind.inspect}"
49
+ end
50
+ end
51
+
52
+ def write_valid_fixture(path, fixture)
53
+ format = build_format(fixture.fetch("format"))
54
+ samples = fixture.fetch("samples")
55
+ buffer = Wavify::Core::SampleBuffer.new(samples, format)
56
+ Wavify::Codecs::Wav.write(path, buffer)
57
+ end
58
+
59
+ def write_truncated_fixture(path, fixture)
60
+ Tempfile.create(["wavify_fixture", ".wav"]) do |tmp|
61
+ write_valid_fixture(tmp.path, fixture)
62
+ bytes = File.binread(tmp.path)
63
+ truncated = bytes[0, [bytes.bytesize / 2, 1].max]
64
+ File.binwrite(path, truncated)
65
+ end
66
+ end
67
+
68
+ def build_format(params)
69
+ Wavify::Core::Format.new(
70
+ channels: params.fetch("channels"),
71
+ sample_rate: params.fetch("sample_rate"),
72
+ bit_depth: params.fetch("bit_depth"),
73
+ sample_format: params.fetch("sample_format", "pcm").to_sym
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ root = File.expand_path("..", __dir__)
81
+ writer = Wavify::Tools::FixtureWriter.new(
82
+ yaml_dir: File.join(root, "spec/fixtures/yaml"),
83
+ audio_dir: File.join(root, "spec/fixtures/audio")
84
+ )
85
+ writer.run