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
|
@@ -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
|
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
|