patchmaster 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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