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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +73 -0
  5. data/Rakefile +48 -0
  6. data/lib/webmidi/access.rb +170 -0
  7. data/lib/webmidi/callback_subscription.rb +26 -0
  8. data/lib/webmidi/clock.rb +129 -0
  9. data/lib/webmidi/configuration.rb +43 -0
  10. data/lib/webmidi/error.rb +26 -0
  11. data/lib/webmidi/message/base.rb +64 -0
  12. data/lib/webmidi/message/channel.rb +238 -0
  13. data/lib/webmidi/message/parser.rb +308 -0
  14. data/lib/webmidi/message/system.rb +162 -0
  15. data/lib/webmidi/message/ump.rb +675 -0
  16. data/lib/webmidi/message.rb +154 -0
  17. data/lib/webmidi/middleware/base.rb +16 -0
  18. data/lib/webmidi/middleware/channel_map.rb +36 -0
  19. data/lib/webmidi/middleware/filter.rb +22 -0
  20. data/lib/webmidi/middleware/logger.rb +17 -0
  21. data/lib/webmidi/middleware/note_range_filter.rb +34 -0
  22. data/lib/webmidi/middleware/panic.rb +73 -0
  23. data/lib/webmidi/middleware/pipeline.rb +19 -0
  24. data/lib/webmidi/middleware/recorder.rb +123 -0
  25. data/lib/webmidi/middleware/split_by_channel.rb +66 -0
  26. data/lib/webmidi/middleware/stack.rb +55 -0
  27. data/lib/webmidi/middleware/timing_gate.rb +58 -0
  28. data/lib/webmidi/middleware/transpose.rb +30 -0
  29. data/lib/webmidi/middleware/velocity_clamp.rb +37 -0
  30. data/lib/webmidi/middleware/velocity_scale.rb +55 -0
  31. data/lib/webmidi/middleware.rb +21 -0
  32. data/lib/webmidi/music/chord.rb +90 -0
  33. data/lib/webmidi/music/note.rb +102 -0
  34. data/lib/webmidi/music/rhythm.rb +92 -0
  35. data/lib/webmidi/music/scale.rb +85 -0
  36. data/lib/webmidi/music.rb +24 -0
  37. data/lib/webmidi/network/apple_midi.rb +189 -0
  38. data/lib/webmidi/network/osc.rb +205 -0
  39. data/lib/webmidi/network/rtp.rb +410 -0
  40. data/lib/webmidi/network.rb +10 -0
  41. data/lib/webmidi/port/base.rb +89 -0
  42. data/lib/webmidi/port/input.rb +158 -0
  43. data/lib/webmidi/port/map.rb +65 -0
  44. data/lib/webmidi/port/output.rb +208 -0
  45. data/lib/webmidi/port.rb +11 -0
  46. data/lib/webmidi/smf/event.rb +206 -0
  47. data/lib/webmidi/smf/reader.rb +237 -0
  48. data/lib/webmidi/smf/sequence.rb +135 -0
  49. data/lib/webmidi/smf/tempo_map.rb +107 -0
  50. data/lib/webmidi/smf/track.rb +130 -0
  51. data/lib/webmidi/smf/writer.rb +121 -0
  52. data/lib/webmidi/smf.rb +13 -0
  53. data/lib/webmidi/transport/adapter.rb +46 -0
  54. data/lib/webmidi/transport/base.rb +59 -0
  55. data/lib/webmidi/transport/device_info.rb +7 -0
  56. data/lib/webmidi/transport/null.rb +81 -0
  57. data/lib/webmidi/transport/virtual.rb +184 -0
  58. data/lib/webmidi/transport.rb +80 -0
  59. data/lib/webmidi/version.rb +5 -0
  60. data/lib/webmidi/virtual/loopback.rb +45 -0
  61. data/lib/webmidi/virtual/port.rb +48 -0
  62. data/lib/webmidi/virtual.rb +9 -0
  63. data/lib/webmidi.rb +19 -0
  64. data/webmidi.gemspec +32 -0
  65. 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