patchmaster 0.0.0 → 0.0.1

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.
@@ -14,27 +14,39 @@ class DSL
14
14
 
15
15
  def load(file)
16
16
  contents = IO.read(file)
17
+ @triggers = []
17
18
  @filters = []
19
+ @songs = {} # key = name, value = song
18
20
  instance_eval(contents)
21
+ read_triggers(contents)
19
22
  read_filters(contents)
20
23
  end
21
24
 
22
25
  def input(port_num, sym, name=nil)
23
- @pm.inputs[sym] = InputDevice.new(name || sym.to_s, port_num, @no_midi)
26
+ @pm.inputs[sym] = InputInstrument.new(name || sym.to_s, port_num, @no_midi)
24
27
  rescue => ex
25
- raise "error creating input device \"#{name}\" on input port #{port_num}: #{ex}"
28
+ raise "input: error creating input instrument \"#{name}\" on input port #{port_num}: #{ex}"
26
29
  end
27
30
  alias_method :in, :input
28
31
 
29
32
  def output(port_num, sym, name=nil)
30
- @pm.outputs[sym] = OutputDevice.new(name || sym.to_s, port_num, @no_midi)
33
+ @pm.outputs[sym] = OutputInstrument.new(name || sym.to_s, port_num, @no_midi)
31
34
  rescue => ex
32
- raise "error creating output device \"#{name}\" on output port #{port_num}: #{ex}"
35
+ raise "output: error creating output instrument \"#{name}\" on output port #{port_num}: #{ex}"
33
36
  end
34
37
  alias_method :out, :output
35
38
 
39
+ def trigger(instrument_sym, bytes, &block)
40
+ instrument = @pm.inputs[instrument_sym]
41
+ raise "trigger: error finding instrument #{instrument_sym}" unless instrument
42
+ t = Trigger.new(bytes, block)
43
+ instrument.triggers << t
44
+ @triggers << t
45
+ end
46
+
36
47
  def song(name)
37
48
  @song = Song.new(name) # ctor saves into @pm.all_songs
49
+ @songs[name] = @song
38
50
  yield @song if block_given?
39
51
  end
40
52
 
@@ -50,6 +62,7 @@ class DSL
50
62
 
51
63
  def connection(in_sym, in_chan, out_sym, out_chan)
52
64
  input = @pm.inputs[in_sym]
65
+ in_chan = nil if in_chan == :all || in_chan == :any
53
66
  raise "can't find input instrument #{in_sym}" unless input
54
67
  output = @pm.outputs[out_sym]
55
68
  raise "can't find outputput instrument #{out_sym}" unless output
@@ -95,7 +108,7 @@ class DSL
95
108
  sl = SongList.new(name)
96
109
  @pm.song_lists << sl
97
110
  song_names.each do |sn|
98
- song = @pm.all_songs.find(sn)
111
+ song = @songs[sn]
99
112
  raise "song \"#{sn}\" not found (song list \"#{name}\")" unless song
100
113
  sl << song
101
114
  end
@@ -106,18 +119,29 @@ class DSL
106
119
  def save(file)
107
120
  File.open(file, 'w') { |f|
108
121
  save_instruments(f)
122
+ save_triggers(f)
109
123
  save_songs(f)
110
124
  save_song_lists(f)
111
125
  }
112
126
  end
113
127
 
114
128
  def save_instruments(f)
115
- @pm.inputs.each { |sym, instr|
129
+ @pm.inputs.each do |sym, instr|
116
130
  f.puts "input #{instr.port_num}, :#{sym}, #{quoted(instr.name)}"
117
- }
118
- @pm.outputs.each { |sym, instr|
131
+ end
132
+ @pm.outputs.each do |sym, instr|
119
133
  f.puts "output #{instr.port_num}, :#{sym}, #{quoted(instr.name)}"
120
- }
134
+ end
135
+ f.puts
136
+ end
137
+
138
+ def save_triggers(f)
139
+ @pm.inputs.each do |sym, instrument|
140
+ instrument.triggers.each do |trigger|
141
+ str = "trigger :#{sym}, #{trigger.bytes.inspect} #{trigger.text}"
142
+ f.puts str
143
+ end
144
+ end
121
145
  f.puts
122
146
  end
123
147
 
@@ -138,11 +162,11 @@ class DSL
138
162
  end
139
163
 
140
164
  def save_connection(f, conn)
141
- in_sym = @pm.inputs.patch(conn.input)
165
+ in_sym = @pm.inputs.key(conn.input)
142
166
  in_chan = conn.input_chan ? conn.input_chan + 1 : 'nil'
143
- out_sym = @pm.outputs.patch(conn.output)
167
+ out_sym = @pm.outputs.key(conn.output)
144
168
  out_chan = conn.output_chan + 1
145
- f.puts " conn :#{in_sym}, #{in_chan}, #{out_sym}, #{out_chan} do"
169
+ f.puts " conn :#{in_sym}, #{in_chan}, :#{out_sym}, #{out_chan} do"
146
170
  f.puts " prog_chg #{conn.pc_prog}" if conn.pc?
147
171
  f.puts " zone #{conn.note_num_to_name(conn.zone.begin)}, #{conn.note_num_to_name(conn.zone.end)}" if conn.zone
148
172
  f.puts " xpose #{conn.xpose}" if conn.xpose
@@ -185,42 +209,51 @@ class DSL
185
209
  end
186
210
  end
187
211
 
188
- # Extremely simple filter text reader. Relies on indentation to detect end
189
- # of filter block.
212
+ def read_triggers(contents)
213
+ read_block_text('trigger', @triggers, contents)
214
+ end
215
+
190
216
  def read_filters(contents)
191
- i = 0
192
- in_filter = false
193
- filter_indentation = nil
194
- filter_end_token = nil
217
+ read_block_text('filter', @filters, contents)
218
+ end
219
+
220
+ # Extremely simple block text reader. Relies on indentation to detect end
221
+ # of code block.
222
+ def read_block_text(name, containers, contents)
223
+ i = -1
224
+ in_block = false
225
+ block_indentation = nil
226
+ block_end_token = nil
195
227
  contents.each_line do |line|
196
- if line =~ /^(\s*)filter\s*(.*)/
197
- filter_indentation, text = $1, $2
198
- @filters[i].text = text + "\n"
199
- in_filter = true
200
- filter_end_token = case text
228
+ if line =~ /^(\s*)#{name}\s*.*?(({|do|->\s*{|lambda\s*{)(.*))/
229
+ block_indentation, text = $1, $2
230
+ i += 1
231
+ containers[i].text = text + "\n"
232
+ in_block = true
233
+ block_end_token = case text
201
234
  when /^{/
202
235
  "}"
203
236
  when /^do\b/
204
237
  "end"
205
- when /^lambda\s*({|do)/
206
- $1 == "{" ? "}" : "end"
238
+ when /^(->|lambda)\s*({|do)/
239
+ $2 == "{" ? "}" : "end"
207
240
  else
208
241
  "}|end" # regex
209
242
  end
210
- elsif in_filter
243
+ elsif in_block
211
244
  line =~ /^(\s*)(.*)/
212
245
  indentation, text = $1, $2
213
- if indentation.length <= filter_indentation.length
214
- if text =~ /^#{filter_end_token}/
215
- @filters[i].text << line
246
+ if indentation.length <= block_indentation.length
247
+ if text =~ /^#{block_end_token}/
248
+ containers[i].text << line
216
249
  end
217
- i += 1
218
- in_filter = false
250
+ in_block = false
219
251
  else
220
- @filters[i].text << line
252
+ containers[i].text << line
221
253
  end
222
254
  end
223
255
  end
256
+ containers.each { |thing| thing.text.strip! }
224
257
  end
225
258
 
226
259
  end
@@ -16,7 +16,7 @@ class Filter
16
16
  end
17
17
 
18
18
  def to_s
19
- @text.gsub("\n", "; ")
19
+ @text.gsub(/\n\s*/, "; ")
20
20
  end
21
21
 
22
22
  end
@@ -1,9 +1,11 @@
1
+ require 'midi-eye'
2
+
1
3
  # Ports are UniMIDI inputs or outputs.
2
4
  module PM
3
5
 
4
- class MIDIDevice
6
+ class Instrument
5
7
 
6
- attr_reader :name, :port_num, :port
8
+ attr_reader :name, :port_num, :port, :listener
7
9
 
8
10
  def initialize(name, port_num, port)
9
11
  @name, @port_num, @port = name, port_num, port
@@ -11,16 +13,17 @@ class MIDIDevice
11
13
 
12
14
  end
13
15
 
14
- # When a connection is started, it adds itself to this InputDevice's
16
+ # When a connection is started, it adds itself to this InputInstrument's
15
17
  # +@connections+. When it ends, it removes itself.
16
- class InputDevice < MIDIDevice
18
+ class InputInstrument < Instrument
17
19
 
18
- attr_accessor :connections
20
+ attr_accessor :connections, :triggers
19
21
 
20
22
  # If +port+ is nil (the normal case), creates either a real or a mock port
21
23
  def initialize(name, port_num, no_midi=false)
22
24
  super(name, port_num, input_port(port_num, no_midi))
23
25
  @connections = []
26
+ @triggers = []
24
27
  end
25
28
 
26
29
  def add_connection(conn)
@@ -32,12 +35,22 @@ class InputDevice < MIDIDevice
32
35
  end
33
36
 
34
37
  # Poll for more MIDI input and process it.
35
- def gets_data
36
- @port.gets_data.each { |bytes| midi_in(bytes) }
38
+ def start
39
+ PatchMaster.instance.debug("instrument #{name} start")
40
+ @port.clear_buffer
41
+ @listener = MIDIEye::Listener.new(@port).listen_for { |event| midi_in(event[:message].to_bytes) }
42
+ @listener.run(:background => true)
43
+ end
44
+
45
+ def stop
46
+ PatchMaster.instance.debug("instrument #{name} stop")
47
+ @port.clear_buffer
48
+ @listener.close
37
49
  end
38
50
 
39
- # Passes MIDI bytes on to each output connection
51
+ # Passes MIDI bytes on to triggers and to each output connection.
40
52
  def midi_in(bytes)
53
+ @triggers.each { |trigger| trigger.signal(bytes) }
41
54
  @connections.each { |conn| conn.midi_in(bytes) }
42
55
  end
43
56
 
@@ -53,7 +66,7 @@ class InputDevice < MIDIDevice
53
66
 
54
67
  end
55
68
 
56
- class OutputDevice < MIDIDevice
69
+ class OutputInstrument < Instrument
57
70
 
58
71
  def initialize(name, port_num, no_midi=false)
59
72
  super(name, port_num, output_port(port_num, no_midi))
@@ -75,8 +88,8 @@ class OutputDevice < MIDIDevice
75
88
  end
76
89
 
77
90
  class MockInputPort
78
- def gets_data
79
- []
91
+ def gets
92
+ [{:data => [], :timestamp => 0}]
80
93
  end
81
94
  end
82
95
 
@@ -2,12 +2,11 @@ module PM
2
2
 
3
3
  class Patch
4
4
 
5
- attr_accessor :name, :connections, :start_bytes
5
+ attr_accessor :name, :connections, :start_bytes, :stop_bytes
6
6
 
7
- def initialize(name, start_bytes=nil)
8
- @name = name
7
+ def initialize(name, start_bytes=nil, stop_bytes=nil)
8
+ @name, @start_bytes, @stop_bytes = name, start_bytes, stop_bytes
9
9
  @connections = []
10
- @start_bytes = start_bytes
11
10
  @running = false
12
11
  end
13
12
 
@@ -19,17 +18,11 @@ class Patch
19
18
  @connections.map(&:input).uniq
20
19
  end
21
20
 
22
- # Send start_bytes to each connection, then spawn a new thread that
23
- # receives input and passes it on to each connection.
21
+ # Send start_bytes to each connection.
24
22
  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
23
+ unless @running
24
+ @connections.each { |conn| conn.start(@start_bytes) }
25
+ @running = true
33
26
  end
34
27
  end
35
28
 
@@ -37,12 +30,12 @@ class Patch
37
30
  @running
38
31
  end
39
32
 
33
+ # Send stop_bytes to each connection, then call #stop on each connection.
40
34
  def stop
41
35
  if @running
42
36
  @running = false
43
- @connections.map(&:stop)
37
+ @connections.each { |conn| conn.stop(@stop_bytes) }
44
38
  end
45
39
  end
46
40
  end
47
-
48
- end # PM
41
+ end
@@ -1,5 +1,7 @@
1
1
  require 'singleton'
2
+ require 'delegate'
2
3
  require 'patchmaster/sorted_song_list'
4
+ require 'patchmaster/cursor'
3
5
 
4
6
  module PM
5
7
 
@@ -12,157 +14,139 @@ module PM
12
14
  # PatchMaster.instance.start
13
15
  # # ...when you're done
14
16
  # PatchMaster.instance.stop
15
- class PatchMaster
17
+ class PatchMaster < SimpleDelegator
18
+
19
+ DEBUG_FILE = '/tmp/pm_debug.txt'
16
20
 
17
21
  include Singleton
18
22
 
19
23
  attr_reader :inputs, :outputs, :all_songs, :song_lists, :no_midi
20
- attr_reader :curr_song_list, :curr_song, :curr_patch
24
+
25
+ # A Cursor to which we delegate incoming position methods (#song_list,
26
+ # #song, #patch, #next_song, #prev_patch, etc.)
27
+ attr_reader :cursor
21
28
 
22
29
  def initialize
23
- init_data
30
+ @cursor = Cursor.new(self)
31
+ super(@cursor)
32
+
33
+ if $DEBUG
34
+ @debug_file = File.open(DEBUG_FILE, 'a')
35
+ end
24
36
  @no_midi = false
25
- @curr_song_list = @curr_song = @curr_patch = nil
37
+
38
+ init_data
26
39
  end
27
40
 
28
41
  def no_midi!
29
42
  @no_midi = true
30
43
  end
31
44
 
45
+ # Loads +file+. Does its best to restore the current song list, song, and
46
+ # patch after loading.
32
47
  def load(file)
48
+ restart = running?
33
49
  stop
50
+
51
+ @cursor.mark
34
52
  init_data
35
53
  DSL.new(@no_midi).load(file)
54
+ @loaded_file = file
55
+ @cursor.restore
56
+
57
+ if restart
58
+ start(false)
59
+ elsif @cursor.patch
60
+ @cursor.patch.start
61
+ end
36
62
  rescue => ex
37
63
  raise("error loading #{file}: #{ex}\n" + caller.join("\n"))
38
64
  end
39
65
 
40
66
  def save(file)
41
67
  DSL.new(@no_midi).save(file)
42
- message("saved #{file}")
68
+ @loaded_file = file
43
69
  rescue => ex
44
- raise("error saving #{file}: #{ex}")
70
+ raise("error saving #{file}: #{ex}" + caller.join("\n"))
45
71
  end
46
72
 
73
+ # Initializes the cursor and all data.
47
74
  def init_data
48
- @curr_song_list = @curr_song = @curr_patch = nil
75
+ @cursor.clear
49
76
  @inputs = {}
50
77
  @outputs = {}
51
- @song_lists = List.new
78
+ @song_lists = []
52
79
  @all_songs = SortedSongList.new('All Songs')
53
80
  @song_lists << @all_songs
54
81
  end
55
82
 
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
83
+ # If +init_cursor+ is +true+ (the default), initializes current song list,
84
+ # song, and patch.
85
+ def start(init_cursor = true)
86
+ @cursor.init if init_cursor
87
+ @cursor.patch.start if @cursor.patch
88
+ @running = true
89
+ @inputs.values.map(&:start)
65
90
  end
66
91
 
67
92
  def stop
68
- @curr_patch.stop if @curr_patch
69
- @curr_song_list = @curr_song = @curr_patch = nil
93
+ @cursor.patch.stop if @cursor.patch
94
+ @inputs.values.map(&:stop)
95
+ @running = false
70
96
  end
71
97
 
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
98
+ def running?
99
+ @running
80
100
  end
81
101
 
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
102
+ # Sends the +CM_ALL_NOTES_OFF+ controller message to all output
103
+ # instruments on all 16 MIDI channels. If +individual_notes+ is +true+
104
+ # send individual +NOTE_OFF+ messages to all notes as well.
105
+ def panic(individual_notes=false)
106
+ debug("panic(#{individual_notes})")
107
+ @outputs.values.each do |out|
108
+ MIDI_CHANNELS.times do |chan|
109
+ out.midi_out([CONTROLLER + chan, CM_ALL_NOTES_OFF, 0])
110
+ if individual_notes
111
+ 128.times { |note| out.midi_out([NOTE_OFF + chan, note, 0]) }
112
+ end
113
+ end
100
114
  end
101
115
  end
102
116
 
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
117
+ # Opens the most recently loaded/saved file name in an editor. After
118
+ # editing, the file is re-loaded.
119
+ def edit
120
+ editor_command = find_editor
121
+ unless editor_command
122
+ message("Can not find $VISUAL, $EDITOR, vim, or vi on your path")
123
+ return
111
124
  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
 
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
126
+ cmd = "#{editor_command} #{@loaded_file}"
127
+ debug(cmd)
128
+ system(cmd)
129
+ load(@loaded_file)
138
130
  end
139
131
 
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
132
+ # Return the first legit command from $VISUAL, $EDITOR, vim, vi, and
133
+ # notepad.exe.
134
+ def find_editor
135
+ @editor ||= [ENV['VISUAL'], ENV['EDITOR'], 'vim', 'vi', 'notepad.exe'].compact.detect do |cmd|
136
+ system('which', cmd) || File.exist?(cmd)
154
137
  end
155
- @curr_song = new_song
156
- @curr_patch = new_patch
157
138
  end
158
139
 
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
140
+ # Output +str+ to @debug_file or $stderr.
141
+ def debug(str)
142
+ return unless $DEBUG
143
+ f = @debug_file || $stderr
144
+ f.puts str
145
+ f.flush
165
146
  end
166
147
 
148
+ def close_debug_file
149
+ @debug_file.close if @debug_file
150
+ end
167
151
  end
168
152
  end