patchmaster 0.0.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.
- data/bin/patchmaster +20 -0
- data/lib/patchmaster.rb +15 -0
- data/lib/patchmaster/app/info_window.rb +32 -0
- data/lib/patchmaster/app/info_window_contents.txt +15 -0
- data/lib/patchmaster/app/list_window.rb +26 -0
- data/lib/patchmaster/app/main.rb +161 -0
- data/lib/patchmaster/app/patch_window.rb +61 -0
- data/lib/patchmaster/app/pm_window.rb +41 -0
- data/lib/patchmaster/app/prompt_window.rb +61 -0
- data/lib/patchmaster/connection.rb +90 -0
- data/lib/patchmaster/consts.rb +439 -0
- data/lib/patchmaster/dsl.rb +227 -0
- data/lib/patchmaster/filter.rb +23 -0
- data/lib/patchmaster/io.rb +87 -0
- data/lib/patchmaster/list.rb +121 -0
- data/lib/patchmaster/list_container.rb +36 -0
- data/lib/patchmaster/patch.rb +48 -0
- data/lib/patchmaster/patchmaster.rb +168 -0
- data/lib/patchmaster/predicates.rb +125 -0
- data/lib/patchmaster/song.rb +24 -0
- data/lib/patchmaster/song_list.rb +39 -0
- data/lib/patchmaster/sorted_song_list.rb +15 -0
- data/test/test_helper.rb +58 -0
- metadata +88 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module PM
|
|
2
|
+
|
|
3
|
+
# Filters are blocks of code executed by a Connection to modify incoming
|
|
4
|
+
# MIDI bytes. Since we want to save them to files, we store the text
|
|
5
|
+
# representation as well.
|
|
6
|
+
class Filter
|
|
7
|
+
|
|
8
|
+
attr_accessor :block, :text
|
|
9
|
+
|
|
10
|
+
def initialize(block, text=nil)
|
|
11
|
+
@block, @text = block, text
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(conn, bytes)
|
|
15
|
+
@block.call(conn, bytes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
@text.gsub("\n", "; ")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Ports are UniMIDI inputs or outputs.
|
|
2
|
+
module PM
|
|
3
|
+
|
|
4
|
+
class MIDIDevice
|
|
5
|
+
|
|
6
|
+
attr_reader :name, :port_num, :port
|
|
7
|
+
|
|
8
|
+
def initialize(name, port_num, port)
|
|
9
|
+
@name, @port_num, @port = name, port_num, port
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# When a connection is started, it adds itself to this InputDevice's
|
|
15
|
+
# +@connections+. When it ends, it removes itself.
|
|
16
|
+
class InputDevice < MIDIDevice
|
|
17
|
+
|
|
18
|
+
attr_accessor :connections
|
|
19
|
+
|
|
20
|
+
# If +port+ is nil (the normal case), creates either a real or a mock port
|
|
21
|
+
def initialize(name, port_num, no_midi=false)
|
|
22
|
+
super(name, port_num, input_port(port_num, no_midi))
|
|
23
|
+
@connections = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_connection(conn)
|
|
27
|
+
@connections << conn
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remove_connection(conn)
|
|
31
|
+
@connections.delete(conn)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Poll for more MIDI input and process it.
|
|
35
|
+
def gets_data
|
|
36
|
+
@port.gets_data.each { |bytes| midi_in(bytes) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Passes MIDI bytes on to each output connection
|
|
40
|
+
def midi_in(bytes)
|
|
41
|
+
@connections.each { |conn| conn.midi_in(bytes) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def input_port(port_num, no_midi=false)
|
|
47
|
+
if no_midi
|
|
48
|
+
MockInputPort.new
|
|
49
|
+
else
|
|
50
|
+
UniMIDI::Input.all[port_num].open
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class OutputDevice < MIDIDevice
|
|
57
|
+
|
|
58
|
+
def initialize(name, port_num, no_midi=false)
|
|
59
|
+
super(name, port_num, output_port(port_num, no_midi))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def midi_out(bytes)
|
|
63
|
+
@port.puts bytes
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def output_port(port_num, no_midi)
|
|
69
|
+
if no_midi
|
|
70
|
+
MockOutputPort.new
|
|
71
|
+
else
|
|
72
|
+
UniMIDI::Output.all[port_num].open
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class MockInputPort
|
|
78
|
+
def gets_data
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class MockOutputPort
|
|
84
|
+
def puts(data)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module PM
|
|
2
|
+
|
|
3
|
+
# A list (Array) of things with a cursor. +@curr+ is an integer index into
|
|
4
|
+
# an array of data.
|
|
5
|
+
class List
|
|
6
|
+
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
# If +enter_sym+ or +exit_sym+ are defined, they will be sent to a data
|
|
10
|
+
# element when it becomes the current element or stops being the current
|
|
11
|
+
# element.
|
|
12
|
+
def initialize
|
|
13
|
+
@data = []
|
|
14
|
+
@curr = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Adding data does not modify the cursor.
|
|
18
|
+
def <<(data)
|
|
19
|
+
@data << data
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Inserts +data+ before +before_this+. If +before_this+ is not in our
|
|
23
|
+
# list, +data+ is inserted at the beginning of the list.
|
|
24
|
+
def insert_before(before_this, data)
|
|
25
|
+
idx = @data.index(before_this) || 0
|
|
26
|
+
@data[idx, 0] = data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Inserts +data+ after +after_this+. If +after_this+ is not in our list,
|
|
30
|
+
# +data+ is inserted at the end of the list.
|
|
31
|
+
def insert_after(after_this, data)
|
|
32
|
+
idx = @data.index(after_this) || @data.length-1
|
|
33
|
+
@data[idx + 1, 0] = data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@data.size
|
|
38
|
+
end
|
|
39
|
+
alias_method :length, :size
|
|
40
|
+
|
|
41
|
+
def first?
|
|
42
|
+
@curr == 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def first
|
|
46
|
+
if !@data.empty? && @curr != 0
|
|
47
|
+
@curr = 0
|
|
48
|
+
end
|
|
49
|
+
curr
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def next
|
|
53
|
+
if @curr == nil || @curr >= @data.length - 1
|
|
54
|
+
@curr = nil
|
|
55
|
+
else
|
|
56
|
+
@curr += 1
|
|
57
|
+
end
|
|
58
|
+
curr
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def curr
|
|
62
|
+
@curr ? @data[@curr] : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# This does not change what is stored at the current location. Rather,
|
|
66
|
+
# it moves this list's cursor to point to +data+ and returns +data+.
|
|
67
|
+
def curr=(data)
|
|
68
|
+
if curr != data
|
|
69
|
+
@curr = @data.index(data)
|
|
70
|
+
end
|
|
71
|
+
data
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the +n+th data element. This method is not normally used to
|
|
75
|
+
# access data because it does not change the cursor. It is used to peek
|
|
76
|
+
# into the list's data array, for example during testing.
|
|
77
|
+
def [](n)
|
|
78
|
+
@data[n]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prev
|
|
82
|
+
if @curr == nil || @curr == 0
|
|
83
|
+
@curr = nil
|
|
84
|
+
else
|
|
85
|
+
@curr -= 1
|
|
86
|
+
end
|
|
87
|
+
curr
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def last
|
|
91
|
+
if !@data.empty? && !last?
|
|
92
|
+
@curr = @data.length - 1
|
|
93
|
+
end
|
|
94
|
+
curr
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def last?
|
|
98
|
+
@curr == nil || @curr == @data.length - 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def remove(data)
|
|
102
|
+
return unless @data.include?(data)
|
|
103
|
+
@data[@data.index(data), 1] = []
|
|
104
|
+
if @data.empty?
|
|
105
|
+
@curr = nil
|
|
106
|
+
elsif @curr >= @data.length
|
|
107
|
+
@curr = @data.length - 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def each
|
|
112
|
+
@data.each { |data| yield data }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# For debugging
|
|
116
|
+
def to_s
|
|
117
|
+
"List(#{@data.empty? ? 'empty' : @data[0].class.name}), size #{size}, curr index #{@curr}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module PM
|
|
2
|
+
|
|
3
|
+
# A ListContainer responds to messages that manipulate the cursors for one
|
|
4
|
+
# or more List objects.
|
|
5
|
+
module ListContainer
|
|
6
|
+
|
|
7
|
+
def method_missing(sym, *args)
|
|
8
|
+
case sym.to_s
|
|
9
|
+
when /^curr_(\w+)=$/
|
|
10
|
+
name = $1
|
|
11
|
+
ivar_sym = "@#{pluralize(name)}".to_sym
|
|
12
|
+
raise "no such ivar #{ivar_sym} in #{self.class}" unless instance_variable_defined?(ivar_sym)
|
|
13
|
+
instance_variable_get(ivar_sym).send(:curr=, args[0])
|
|
14
|
+
when /^(first|next|prev|curr|last)_(\w+)(\?)?$/
|
|
15
|
+
method, ivar, qmark = $1, $2, $3
|
|
16
|
+
ivar_sym = "@#{pluralize(ivar)}".to_sym
|
|
17
|
+
raise "no such ivar #{ivar_sym} in #{self.class}" unless instance_variable_defined?(ivar_sym)
|
|
18
|
+
instance_variable_get(ivar_sym).send("#{method}#{qmark}".to_sym)
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pluralize(str)
|
|
25
|
+
case str
|
|
26
|
+
when /s$/, /ch$/
|
|
27
|
+
"#{str}es"
|
|
28
|
+
when /y$/
|
|
29
|
+
"#{str[0..-2]}ies}"
|
|
30
|
+
else
|
|
31
|
+
"#{str}s"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module PM
|
|
2
|
+
|
|
3
|
+
class Patch
|
|
4
|
+
|
|
5
|
+
attr_accessor :name, :connections, :start_bytes
|
|
6
|
+
|
|
7
|
+
def initialize(name, start_bytes=nil)
|
|
8
|
+
@name = name
|
|
9
|
+
@connections = []
|
|
10
|
+
@start_bytes = start_bytes
|
|
11
|
+
@running = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def <<(conn)
|
|
15
|
+
@connections << conn
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def inputs
|
|
19
|
+
@connections.map(&:input).uniq
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Send start_bytes to each connection, then spawn a new thread that
|
|
23
|
+
# receives input and passes it on to each connection.
|
|
24
|
+
def start
|
|
25
|
+
input_devices = inputs()
|
|
26
|
+
@connections.each { |conn| conn.start(@start_bytes) }
|
|
27
|
+
@running = true
|
|
28
|
+
Thread.new do
|
|
29
|
+
loop do
|
|
30
|
+
break unless @running
|
|
31
|
+
input_devices.map(&:gets_data)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def running?
|
|
37
|
+
@running
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
if @running
|
|
42
|
+
@running = false
|
|
43
|
+
@connections.map(&:stop)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end # PM
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require 'singleton'
|
|
2
|
+
require 'patchmaster/sorted_song_list'
|
|
3
|
+
|
|
4
|
+
module PM
|
|
5
|
+
|
|
6
|
+
# Global behavior: master list of songs, list of song lists, stuff like
|
|
7
|
+
# that.
|
|
8
|
+
#
|
|
9
|
+
# Typical use:
|
|
10
|
+
#
|
|
11
|
+
# PatchMaster.instance.load("my_pm_dsl_file")
|
|
12
|
+
# PatchMaster.instance.start
|
|
13
|
+
# # ...when you're done
|
|
14
|
+
# PatchMaster.instance.stop
|
|
15
|
+
class PatchMaster
|
|
16
|
+
|
|
17
|
+
include Singleton
|
|
18
|
+
|
|
19
|
+
attr_reader :inputs, :outputs, :all_songs, :song_lists, :no_midi
|
|
20
|
+
attr_reader :curr_song_list, :curr_song, :curr_patch
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
init_data
|
|
24
|
+
@no_midi = false
|
|
25
|
+
@curr_song_list = @curr_song = @curr_patch = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def no_midi!
|
|
29
|
+
@no_midi = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load(file)
|
|
33
|
+
stop
|
|
34
|
+
init_data
|
|
35
|
+
DSL.new(@no_midi).load(file)
|
|
36
|
+
rescue => ex
|
|
37
|
+
raise("error loading #{file}: #{ex}\n" + caller.join("\n"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def save(file)
|
|
41
|
+
DSL.new(@no_midi).save(file)
|
|
42
|
+
message("saved #{file}")
|
|
43
|
+
rescue => ex
|
|
44
|
+
raise("error saving #{file}: #{ex}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def init_data
|
|
48
|
+
@curr_song_list = @curr_song = @curr_patch = nil
|
|
49
|
+
@inputs = {}
|
|
50
|
+
@outputs = {}
|
|
51
|
+
@song_lists = List.new
|
|
52
|
+
@all_songs = SortedSongList.new('All Songs')
|
|
53
|
+
@song_lists << @all_songs
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def start
|
|
57
|
+
@curr_song_list = @song_lists.first # sets cursor in @song_lists
|
|
58
|
+
@curr_song = @curr_song_list.first_song
|
|
59
|
+
if @curr_song
|
|
60
|
+
@curr_patch = @curr_song.first_patch
|
|
61
|
+
@curr_patch.start
|
|
62
|
+
else
|
|
63
|
+
@curr_patch = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop
|
|
68
|
+
@curr_patch.stop if @curr_patch
|
|
69
|
+
@curr_song_list = @curr_song = @curr_patch = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def next_song
|
|
73
|
+
return unless @curr_song_list
|
|
74
|
+
return if @curr_song_list.last_song?
|
|
75
|
+
|
|
76
|
+
@curr_patch.stop if @curr_patch
|
|
77
|
+
@curr_song = @curr_song_list.next_song
|
|
78
|
+
@curr_patch = @curr_song.first_patch
|
|
79
|
+
@curr_patch.start
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prev_song
|
|
83
|
+
return unless @curr_song_list
|
|
84
|
+
return if @curr_song_list.first_song?
|
|
85
|
+
|
|
86
|
+
@curr_patch.stop if @curr_patch
|
|
87
|
+
@curr_song = @curr_song_list.prev_song
|
|
88
|
+
@curr_patch = @curr_song.first_patch
|
|
89
|
+
@curr_patch.start
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def next_patch
|
|
93
|
+
return unless @curr_song
|
|
94
|
+
if @curr_song.last_patch?
|
|
95
|
+
next_song
|
|
96
|
+
elsif @curr_patch
|
|
97
|
+
@curr_patch.stop
|
|
98
|
+
@curr_patch = @curr_song.next_patch
|
|
99
|
+
@curr_patch.start
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def prev_patch
|
|
104
|
+
return unless @curr_song
|
|
105
|
+
if @curr_song.first_patch?
|
|
106
|
+
prev_song
|
|
107
|
+
elsif @curr_patch
|
|
108
|
+
@curr_patch.stop
|
|
109
|
+
@curr_patch = @curr_song.prev_patch
|
|
110
|
+
@curr_patch.start
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def goto_song(name_regex)
|
|
115
|
+
new_song_list = new_song = new_patch = nil
|
|
116
|
+
new_song = @curr_song_list.find(name_regex) if @curr_song_list
|
|
117
|
+
new_song = @all_songs.find(name_regex) unless new_song
|
|
118
|
+
new_patch = new_song ? new_song.first_patch : nil
|
|
119
|
+
|
|
120
|
+
if (new_song && new_song != @curr_song) || # moved to new song
|
|
121
|
+
(new_song == @curr_song && @curr_patch != new_patch) # same song but not at same first patch
|
|
122
|
+
|
|
123
|
+
@curr_patch.stop if @curr_patch
|
|
124
|
+
|
|
125
|
+
if @curr_song_list.songs.include?(new_song)
|
|
126
|
+
new_song_list = @curr_song_list
|
|
127
|
+
else
|
|
128
|
+
# Not found in current song list. Switch to all_songs list.
|
|
129
|
+
new_song_list = @all_songs
|
|
130
|
+
end
|
|
131
|
+
new_song_list.curr_song = new_song # move to that song in selected song list
|
|
132
|
+
|
|
133
|
+
@curr_song_list = new_song_list
|
|
134
|
+
@curr_song = new_song
|
|
135
|
+
@curr_patch = new_patch
|
|
136
|
+
@curr_patch.start
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def goto_song_list(name_regex)
|
|
141
|
+
name_regex = Regexp.new(name_regex.to_s, true) # make case-insensitive
|
|
142
|
+
new_song_list = @song_lists.detect { |song_list| song_list.name =~ name_regex }
|
|
143
|
+
return unless new_song_list
|
|
144
|
+
|
|
145
|
+
@curr_song_list = new_song_list
|
|
146
|
+
@song_lists.curr = new_song_list # set cursor
|
|
147
|
+
|
|
148
|
+
new_song = @curr_song_list.first_song
|
|
149
|
+
new_patch = new_song ? new_song.first_patch : nil
|
|
150
|
+
|
|
151
|
+
if new_patch != @curr_patch
|
|
152
|
+
@curr_patch.stop if @curr_patch
|
|
153
|
+
new_patch.start if new_patch
|
|
154
|
+
end
|
|
155
|
+
@curr_song = new_song
|
|
156
|
+
@curr_patch = new_patch
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def panic
|
|
160
|
+
@outputs.values.each do |out|
|
|
161
|
+
MIDI_CHANNELS.times do |chan|
|
|
162
|
+
out.midi_out([CONTROLLER + chan, CM_ALL_NOTES_OFF, 0])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
end
|
|
168
|
+
end
|