webmidi 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +73 -0
- data/Rakefile +48 -0
- data/lib/webmidi/access.rb +170 -0
- data/lib/webmidi/callback_subscription.rb +26 -0
- data/lib/webmidi/clock.rb +129 -0
- data/lib/webmidi/configuration.rb +43 -0
- data/lib/webmidi/error.rb +26 -0
- data/lib/webmidi/message/base.rb +64 -0
- data/lib/webmidi/message/channel.rb +238 -0
- data/lib/webmidi/message/parser.rb +308 -0
- data/lib/webmidi/message/system.rb +162 -0
- data/lib/webmidi/message/ump.rb +675 -0
- data/lib/webmidi/message.rb +154 -0
- data/lib/webmidi/middleware/base.rb +16 -0
- data/lib/webmidi/middleware/channel_map.rb +36 -0
- data/lib/webmidi/middleware/filter.rb +22 -0
- data/lib/webmidi/middleware/logger.rb +17 -0
- data/lib/webmidi/middleware/note_range_filter.rb +34 -0
- data/lib/webmidi/middleware/panic.rb +73 -0
- data/lib/webmidi/middleware/pipeline.rb +19 -0
- data/lib/webmidi/middleware/recorder.rb +123 -0
- data/lib/webmidi/middleware/split_by_channel.rb +66 -0
- data/lib/webmidi/middleware/stack.rb +55 -0
- data/lib/webmidi/middleware/timing_gate.rb +58 -0
- data/lib/webmidi/middleware/transpose.rb +30 -0
- data/lib/webmidi/middleware/velocity_clamp.rb +37 -0
- data/lib/webmidi/middleware/velocity_scale.rb +55 -0
- data/lib/webmidi/middleware.rb +21 -0
- data/lib/webmidi/music/chord.rb +90 -0
- data/lib/webmidi/music/note.rb +102 -0
- data/lib/webmidi/music/rhythm.rb +92 -0
- data/lib/webmidi/music/scale.rb +85 -0
- data/lib/webmidi/music.rb +24 -0
- data/lib/webmidi/network/apple_midi.rb +189 -0
- data/lib/webmidi/network/osc.rb +205 -0
- data/lib/webmidi/network/rtp.rb +410 -0
- data/lib/webmidi/network.rb +10 -0
- data/lib/webmidi/port/base.rb +89 -0
- data/lib/webmidi/port/input.rb +158 -0
- data/lib/webmidi/port/map.rb +65 -0
- data/lib/webmidi/port/output.rb +208 -0
- data/lib/webmidi/port.rb +11 -0
- data/lib/webmidi/smf/event.rb +206 -0
- data/lib/webmidi/smf/reader.rb +237 -0
- data/lib/webmidi/smf/sequence.rb +135 -0
- data/lib/webmidi/smf/tempo_map.rb +107 -0
- data/lib/webmidi/smf/track.rb +130 -0
- data/lib/webmidi/smf/writer.rb +121 -0
- data/lib/webmidi/smf.rb +13 -0
- data/lib/webmidi/transport/adapter.rb +46 -0
- data/lib/webmidi/transport/base.rb +59 -0
- data/lib/webmidi/transport/device_info.rb +7 -0
- data/lib/webmidi/transport/null.rb +81 -0
- data/lib/webmidi/transport/virtual.rb +184 -0
- data/lib/webmidi/transport.rb +80 -0
- data/lib/webmidi/version.rb +5 -0
- data/lib/webmidi/virtual/loopback.rb +45 -0
- data/lib/webmidi/virtual/port.rb +48 -0
- data/lib/webmidi/virtual.rb +9 -0
- data/lib/webmidi.rb +19 -0
- data/webmidi.gemspec +32 -0
- metadata +108 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Transpose < Base
|
|
6
|
+
def initialize(app, semitones: 0, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
@semitones = semitones
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(message)
|
|
12
|
+
if note_message?(message)
|
|
13
|
+
new_note = (message.note + @semitones).clamp(0, 127)
|
|
14
|
+
transposed = message.with(note: new_note)
|
|
15
|
+
@app.call(transposed)
|
|
16
|
+
else
|
|
17
|
+
@app.call(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def note_message?(msg)
|
|
24
|
+
msg.is_a?(Message::Channel::NoteOn) ||
|
|
25
|
+
msg.is_a?(Message::Channel::NoteOff) ||
|
|
26
|
+
msg.is_a?(Message::Channel::PolyphonicPressure)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class VelocityClamp < Base
|
|
6
|
+
def initialize(app, min: 0, max: 127, include_note_off: true, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
validate_velocity!(min, "min")
|
|
9
|
+
validate_velocity!(max, "max")
|
|
10
|
+
raise InvalidMessageError, "min cannot be greater than max" if min > max
|
|
11
|
+
|
|
12
|
+
@min = min
|
|
13
|
+
@max = max
|
|
14
|
+
@include_note_off = include_note_off
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(message)
|
|
18
|
+
return @app.call(message) unless velocity_message?(message)
|
|
19
|
+
|
|
20
|
+
@app.call(message.with(velocity: message.velocity.clamp(@min, @max)))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def velocity_message?(message)
|
|
26
|
+
message.is_a?(Message::Channel::NoteOn) ||
|
|
27
|
+
(@include_note_off && message.is_a?(Message::Channel::NoteOff))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_velocity!(velocity, name)
|
|
31
|
+
return if velocity.is_a?(Integer) && velocity.between?(0, 127)
|
|
32
|
+
|
|
33
|
+
raise InvalidMessageError, "#{name} must be between 0 and 127, got #{velocity.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class VelocityScale < Base
|
|
6
|
+
def initialize(app, factor: 1.0, min: 0, max: 127, curve: :linear, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
validate_options!(factor, min, max, curve)
|
|
9
|
+
@factor = factor
|
|
10
|
+
@min = min
|
|
11
|
+
@max = max
|
|
12
|
+
@curve = curve
|
|
13
|
+
@include_note_off = options.fetch(:include_note_off, true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(message)
|
|
17
|
+
if velocity_message?(message)
|
|
18
|
+
scaled = apply_curve(message.velocity)
|
|
19
|
+
scaled_msg = message.with(velocity: scaled)
|
|
20
|
+
@app.call(scaled_msg)
|
|
21
|
+
else
|
|
22
|
+
@app.call(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def velocity_message?(msg)
|
|
29
|
+
msg.is_a?(Message::Channel::NoteOn) || (@include_note_off && msg.is_a?(Message::Channel::NoteOff))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def apply_curve(velocity)
|
|
33
|
+
normalized = velocity / 127.0
|
|
34
|
+
result = case @curve
|
|
35
|
+
when :linear
|
|
36
|
+
normalized * @factor
|
|
37
|
+
when :exponential
|
|
38
|
+
(normalized**2) * @factor
|
|
39
|
+
when :logarithmic
|
|
40
|
+
Math.sqrt(normalized) * @factor
|
|
41
|
+
end
|
|
42
|
+
(result * 127).round.clamp(@min, @max)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_options!(factor, min, max, curve)
|
|
46
|
+
raise InvalidMessageError, "Velocity factor must be non-negative" unless factor.is_a?(Numeric) && factor >= 0
|
|
47
|
+
unless min.is_a?(Integer) && min.between?(0, 127) && max.is_a?(Integer) && max.between?(0, 127)
|
|
48
|
+
raise InvalidMessageError, "Velocity min/max must be MIDI data bytes"
|
|
49
|
+
end
|
|
50
|
+
raise InvalidMessageError, "Velocity min cannot be greater than max" if min > max
|
|
51
|
+
raise InvalidMessageError, "Unknown velocity curve: #{curve.inspect}" unless %i[linear exponential logarithmic].include?(curve)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "middleware/base"
|
|
4
|
+
require_relative "middleware/stack"
|
|
5
|
+
require_relative "middleware/logger"
|
|
6
|
+
require_relative "middleware/filter"
|
|
7
|
+
require_relative "middleware/transpose"
|
|
8
|
+
require_relative "middleware/velocity_scale"
|
|
9
|
+
require_relative "middleware/channel_map"
|
|
10
|
+
require_relative "middleware/note_range_filter"
|
|
11
|
+
require_relative "middleware/velocity_clamp"
|
|
12
|
+
require_relative "middleware/timing_gate"
|
|
13
|
+
require_relative "middleware/panic"
|
|
14
|
+
require_relative "middleware/split_by_channel"
|
|
15
|
+
require_relative "middleware/recorder"
|
|
16
|
+
require_relative "middleware/pipeline"
|
|
17
|
+
|
|
18
|
+
module Webmidi
|
|
19
|
+
module Middleware
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Music
|
|
5
|
+
module Chord
|
|
6
|
+
TYPES = {
|
|
7
|
+
major: [0, 4, 7],
|
|
8
|
+
minor: [0, 3, 7],
|
|
9
|
+
dim: [0, 3, 6],
|
|
10
|
+
aug: [0, 4, 8],
|
|
11
|
+
sus2: [0, 2, 7],
|
|
12
|
+
sus4: [0, 5, 7],
|
|
13
|
+
dom7: [0, 4, 7, 10],
|
|
14
|
+
maj7: [0, 4, 7, 11],
|
|
15
|
+
min7: [0, 3, 7, 10],
|
|
16
|
+
dim7: [0, 3, 6, 9],
|
|
17
|
+
half_dim7: [0, 3, 6, 10],
|
|
18
|
+
aug7: [0, 4, 8, 10],
|
|
19
|
+
min_maj7: [0, 3, 7, 11],
|
|
20
|
+
dom9: [0, 4, 7, 10, 14],
|
|
21
|
+
maj9: [0, 4, 7, 11, 14],
|
|
22
|
+
min9: [0, 3, 7, 10, 14],
|
|
23
|
+
dom11: [0, 4, 7, 10, 14, 17],
|
|
24
|
+
dom13: [0, 4, 7, 10, 14, 17, 21],
|
|
25
|
+
add9: [0, 4, 7, 14],
|
|
26
|
+
six: [0, 4, 7, 9],
|
|
27
|
+
min6: [0, 3, 7, 9],
|
|
28
|
+
power: [0, 7]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
@custom_types = {}
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def build(root, type = :major, inversion: 0, range: :strict)
|
|
36
|
+
validate_inversion!(inversion)
|
|
37
|
+
root_midi = Note.to_midi(root)
|
|
38
|
+
intervals = TYPES[type] || @custom_types[type]
|
|
39
|
+
raise InvalidMessageError, "Unknown chord type: #{type}" unless intervals
|
|
40
|
+
|
|
41
|
+
notes = intervals.map { |i| root_midi + i }
|
|
42
|
+
|
|
43
|
+
inversion.times do
|
|
44
|
+
notes.push(notes.shift + 12)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
apply_range_policy(notes, range)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define(name, intervals = nil, &block)
|
|
51
|
+
intervals = block.call(0) if block
|
|
52
|
+
validate_intervals!(intervals)
|
|
53
|
+
@custom_types[name] = intervals.dup.freeze
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def types
|
|
58
|
+
TYPES.keys + @custom_types.keys
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_inversion!(inversion)
|
|
62
|
+
return if inversion.is_a?(Integer) && inversion >= 0
|
|
63
|
+
|
|
64
|
+
raise InvalidMessageError, "Chord inversion must be a non-negative integer, got #{inversion.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_intervals!(intervals)
|
|
68
|
+
unless intervals.respond_to?(:each) && intervals.all? { |interval| interval.is_a?(Integer) }
|
|
69
|
+
raise InvalidMessageError, "Chord intervals must be integers"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_range_policy(notes, range)
|
|
74
|
+
case range
|
|
75
|
+
when :strict
|
|
76
|
+
notes.each { |note| Note.validate_midi!(note) }
|
|
77
|
+
notes
|
|
78
|
+
when :clamp
|
|
79
|
+
notes.map { |note| note.clamp(0, 127) }
|
|
80
|
+
when :allow_out_of_range
|
|
81
|
+
notes
|
|
82
|
+
else
|
|
83
|
+
raise InvalidMessageError, "Unknown range policy: #{range.inspect}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method :validate_inversion!, :validate_intervals!, :apply_range_policy
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Music
|
|
5
|
+
module Note
|
|
6
|
+
NOTE_NAMES = {
|
|
7
|
+
"C" => 0, "D" => 2, "E" => 4, "F" => 5,
|
|
8
|
+
"G" => 7, "A" => 9, "B" => 11
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
MIDI_TO_NAME = %w[C Cs D Ds E F Fs G Gs A As B].freeze
|
|
12
|
+
MIDI_RANGE = 0..127
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def to_midi(input, validate: true)
|
|
17
|
+
midi = case input
|
|
18
|
+
when Integer
|
|
19
|
+
input
|
|
20
|
+
when Symbol
|
|
21
|
+
parse_note_name(input.to_s)
|
|
22
|
+
when String
|
|
23
|
+
parse_note_name(input)
|
|
24
|
+
else
|
|
25
|
+
raise InvalidMessageError, "Cannot convert #{input.class} to MIDI note"
|
|
26
|
+
end
|
|
27
|
+
validate_midi!(midi) if validate
|
|
28
|
+
midi
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_name(midi_number, sharps: true)
|
|
32
|
+
validate_midi!(midi_number)
|
|
33
|
+
octave = (midi_number / 12) - 1
|
|
34
|
+
note_index = midi_number % 12
|
|
35
|
+
name = if sharps
|
|
36
|
+
MIDI_TO_NAME[note_index]
|
|
37
|
+
else
|
|
38
|
+
%w[C Db D Eb E F Gb G Ab A Bb B][note_index]
|
|
39
|
+
end
|
|
40
|
+
"#{name}#{octave}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_frequency(midi_number, a4: 440.0)
|
|
44
|
+
validate_midi!(midi_number)
|
|
45
|
+
validate_frequency_reference!(a4)
|
|
46
|
+
a4 * (2.0**((midi_number - 69) / 12.0))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def from_frequency(freq, a4: 440.0)
|
|
50
|
+
validate_frequency!(freq)
|
|
51
|
+
validate_frequency_reference!(a4)
|
|
52
|
+
(12 * Math.log2(freq / a4) + 69).round
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_note_name(str)
|
|
56
|
+
match = str.match(/\A([A-Ga-g])(ss|\#\#|s|\#|bb|b)?(-?\d+)\z/)
|
|
57
|
+
raise InvalidMessageError, "Invalid note name: #{str}" unless match
|
|
58
|
+
|
|
59
|
+
name = match[1].upcase
|
|
60
|
+
accidental = match[2]
|
|
61
|
+
octave = match[3].to_i
|
|
62
|
+
|
|
63
|
+
base = NOTE_NAMES[name]
|
|
64
|
+
raise InvalidMessageError, "Unknown note: #{name}" unless base
|
|
65
|
+
|
|
66
|
+
midi = base + ((octave + 1) * 12)
|
|
67
|
+
case accidental
|
|
68
|
+
when "s", "#"
|
|
69
|
+
midi += 1
|
|
70
|
+
when "ss", "##"
|
|
71
|
+
midi += 2
|
|
72
|
+
when "b"
|
|
73
|
+
midi -= 1
|
|
74
|
+
when "bb"
|
|
75
|
+
midi -= 2
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
midi
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_midi!(midi_number)
|
|
82
|
+
return if midi_number.is_a?(Integer) && MIDI_RANGE.cover?(midi_number)
|
|
83
|
+
|
|
84
|
+
raise InvalidMessageError, "MIDI note must be between 0 and 127, got #{midi_number.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_frequency!(freq)
|
|
88
|
+
return if freq.is_a?(Numeric) && freq.positive?
|
|
89
|
+
|
|
90
|
+
raise InvalidMessageError, "Frequency must be positive, got #{freq.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_frequency_reference!(a4)
|
|
94
|
+
return if a4.is_a?(Numeric) && a4.positive?
|
|
95
|
+
|
|
96
|
+
raise InvalidMessageError, "A4 reference frequency must be positive, got #{a4.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method :parse_note_name, :validate_frequency!, :validate_frequency_reference!
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Music
|
|
5
|
+
module Rhythm
|
|
6
|
+
DURATIONS = {
|
|
7
|
+
whole: 4.0,
|
|
8
|
+
half: 2.0,
|
|
9
|
+
quarter: 1.0,
|
|
10
|
+
eighth: 0.5,
|
|
11
|
+
sixteenth: 0.25,
|
|
12
|
+
thirty_second: 0.125,
|
|
13
|
+
dotted_whole: 6.0,
|
|
14
|
+
dotted_half: 3.0,
|
|
15
|
+
dotted_quarter: 1.5,
|
|
16
|
+
dotted_eighth: 0.75,
|
|
17
|
+
triplet_quarter: 2.0 / 3.0,
|
|
18
|
+
triplet_eighth: 1.0 / 3.0,
|
|
19
|
+
triplet_sixteenth: 1.0 / 6.0
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def duration_in_beats(name)
|
|
25
|
+
DURATIONS[name] || raise(InvalidMessageError, "Unknown duration: #{name}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def duration_in_ticks(name, ppqn: 480)
|
|
29
|
+
validate_ppqn!(ppqn)
|
|
30
|
+
(duration_in_beats(name) * ppqn).round
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def duration_in_seconds(name, bpm: 120)
|
|
34
|
+
validate_bpm!(bpm)
|
|
35
|
+
duration_in_beats(name) * (60.0 / bpm)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def beats_to_ticks(beats, ppqn: 480)
|
|
39
|
+
validate_beats!(beats)
|
|
40
|
+
validate_ppqn!(ppqn)
|
|
41
|
+
(beats * ppqn).round
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ticks_to_beats(ticks, ppqn: 480)
|
|
45
|
+
validate_ticks!(ticks)
|
|
46
|
+
validate_ppqn!(ppqn)
|
|
47
|
+
ticks.to_f / ppqn
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dotted(name, dots: 1)
|
|
51
|
+
raise InvalidMessageError, "dots must be a non-negative integer" unless dots.is_a?(Integer) && dots >= 0
|
|
52
|
+
|
|
53
|
+
base = duration_in_beats(name)
|
|
54
|
+
dots.times.reduce(base) { |sum, index| sum + (base / (2**(index + 1))) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tuplet(name, in_time_of:, notes:)
|
|
58
|
+
unless in_time_of.is_a?(Integer) && in_time_of.positive? && notes.is_a?(Integer) && notes.positive?
|
|
59
|
+
raise InvalidMessageError, "Tuplet values must be positive integers"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
duration_in_beats(name) * (in_time_of.to_f / notes)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_ppqn!(ppqn)
|
|
66
|
+
return if ppqn.is_a?(Integer) && ppqn.positive?
|
|
67
|
+
|
|
68
|
+
raise InvalidMessageError, "PPQN must be positive, got #{ppqn.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_bpm!(bpm)
|
|
72
|
+
return if bpm.is_a?(Numeric) && bpm.positive?
|
|
73
|
+
|
|
74
|
+
raise InvalidMessageError, "BPM must be positive, got #{bpm.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_beats!(beats)
|
|
78
|
+
return if beats.is_a?(Numeric) && beats >= 0
|
|
79
|
+
|
|
80
|
+
raise InvalidMessageError, "Beats must be non-negative, got #{beats.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_ticks!(ticks)
|
|
84
|
+
return if ticks.is_a?(Numeric) && ticks >= 0
|
|
85
|
+
|
|
86
|
+
raise InvalidMessageError, "Ticks must be non-negative, got #{ticks.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :validate_ppqn!, :validate_bpm!, :validate_beats!, :validate_ticks!
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Music
|
|
5
|
+
module Scale
|
|
6
|
+
TYPES = {
|
|
7
|
+
major: [0, 2, 4, 5, 7, 9, 11],
|
|
8
|
+
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
9
|
+
harmonic_minor: [0, 2, 3, 5, 7, 8, 11],
|
|
10
|
+
melodic_minor: [0, 2, 3, 5, 7, 9, 11],
|
|
11
|
+
dorian: [0, 2, 3, 5, 7, 9, 10],
|
|
12
|
+
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
|
13
|
+
lydian: [0, 2, 4, 6, 7, 9, 11],
|
|
14
|
+
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
|
15
|
+
locrian: [0, 1, 3, 5, 6, 8, 10],
|
|
16
|
+
pentatonic: [0, 2, 4, 7, 9],
|
|
17
|
+
minor_pentatonic: [0, 3, 5, 7, 10],
|
|
18
|
+
blues: [0, 3, 5, 6, 7, 10],
|
|
19
|
+
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
|
20
|
+
whole_tone: [0, 2, 4, 6, 8, 10],
|
|
21
|
+
diminished: [0, 2, 3, 5, 6, 8, 9, 11]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
@custom_types = {}
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def build(root, type = :major, range: :strict)
|
|
29
|
+
root_midi = Note.to_midi(root)
|
|
30
|
+
intervals = TYPES[type] || @custom_types[type]
|
|
31
|
+
raise InvalidMessageError, "Unknown scale type: #{type}" unless intervals
|
|
32
|
+
|
|
33
|
+
apply_range_policy(intervals.map { |i| root_midi + i }, range)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def define(name, intervals)
|
|
37
|
+
validate_intervals!(intervals)
|
|
38
|
+
@custom_types[name] = intervals
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def types
|
|
43
|
+
TYPES.keys + @custom_types.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def degree(root, type, degree_num)
|
|
47
|
+
unless degree_num.is_a?(Integer) && degree_num.positive?
|
|
48
|
+
raise InvalidMessageError, "Scale degree must be a positive integer, got #{degree_num.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
root_midi = Note.to_midi(root)
|
|
52
|
+
intervals = TYPES[type] || @custom_types[type]
|
|
53
|
+
raise InvalidMessageError, "Unknown scale type: #{type}" unless intervals
|
|
54
|
+
|
|
55
|
+
index = (degree_num - 1) % intervals.size
|
|
56
|
+
octave = (degree_num - 1) / intervals.size
|
|
57
|
+
note = root_midi + intervals[index] + (octave * 12)
|
|
58
|
+
Note.validate_midi!(note)
|
|
59
|
+
note
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_intervals!(intervals)
|
|
63
|
+
unless intervals.respond_to?(:each) && intervals.all? { |interval| interval.is_a?(Integer) }
|
|
64
|
+
raise InvalidMessageError, "Scale intervals must be integers"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_range_policy(notes, range)
|
|
69
|
+
case range
|
|
70
|
+
when :strict
|
|
71
|
+
notes.each { |note| Note.validate_midi!(note) }
|
|
72
|
+
notes
|
|
73
|
+
when :clamp
|
|
74
|
+
notes.map { |note| note.clamp(0, 127) }
|
|
75
|
+
when :allow_out_of_range
|
|
76
|
+
notes
|
|
77
|
+
else
|
|
78
|
+
raise InvalidMessageError, "Unknown range policy: #{range.inspect}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :validate_intervals!, :apply_range_policy
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "music/note"
|
|
4
|
+
require_relative "music/chord"
|
|
5
|
+
require_relative "music/scale"
|
|
6
|
+
require_relative "music/rhythm"
|
|
7
|
+
|
|
8
|
+
module Webmidi
|
|
9
|
+
module Music
|
|
10
|
+
def note(name_or_number)
|
|
11
|
+
Note.to_midi(name_or_number)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def chord(root, type = :major, inversion: 0)
|
|
15
|
+
Chord.build(root, type, inversion: inversion)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def scale(root, type = :major)
|
|
19
|
+
Scale.build(root, type)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module_function :note, :chord, :scale
|
|
23
|
+
end
|
|
24
|
+
end
|