patchmaster 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/bin/patchmaster CHANGED
@@ -1,20 +1,28 @@
1
1
  #!/usr/bin/env ruby
2
2
  #
3
- # usage: patchmaster [-i] [pm_file]
3
+ # usage: patchmaster [-n] [-d] [pm_file]
4
4
  #
5
5
  # Starts PatchMaster and optionally loads pm_file.
6
6
  #
7
7
  # The -n flag tells PatchMaster to not use MIDI. All MIDI errors such as not
8
- # being able to connect to the MIDI devices speicified in pm_file are
8
+ # being able to connect to the MIDI instruments specified in pm_file are
9
9
  # ignored, and no MIDI data is sent/received. That is useful if you want to
10
- # run PatchMaster without actually talking to any MIDI devices.
10
+ # run PatchMaster without actually talking to any MIDI instruments.
11
11
 
12
+ require 'optparse'
13
+
14
+ use_midi = true
15
+ OptionParser.new do |opts|
16
+ opts.banner = "usage: patchmaster [options] [pm_file]"
17
+ opts.on("-d", "--debug", "Turn on debug mode") { $DEBUG = true }
18
+ opts.on("-n", "--no-midi", "Turn off MIDI processing") { use_midi = false }
19
+ end.parse!(ARGV)
20
+
21
+ # Must require patchmaster here, after handling options, because Singleton
22
+ # initialze code checks $DEBUG.
12
23
  require 'patchmaster'
13
24
 
14
- pm = PM::Main.instance
15
- if ARGV[0] == '-n'
16
- pm.no_midi!
17
- ARGV.shift
18
- end
19
- pm.load(ARGV[0]) if ARGV[0]
20
- pm.run
25
+ app = PM::Main.instance
26
+ app.no_midi! if !use_midi
27
+ app.load(ARGV[0]) if ARGV[0]
28
+ app.run
data/lib/patchmaster.rb CHANGED
@@ -5,11 +5,8 @@ require 'patchmaster/song'
5
5
  require 'patchmaster/patch'
6
6
  require 'patchmaster/connection'
7
7
  require 'patchmaster/filter'
8
- require 'patchmaster/io'
8
+ require 'patchmaster/instrument'
9
9
  require 'patchmaster/patchmaster'
10
+ require 'patchmaster/trigger'
10
11
  require 'patchmaster/dsl'
11
12
  require 'patchmaster/app/main'
12
-
13
- def message(str)
14
- PM::Main.instance.message(str)
15
- end
@@ -7,12 +7,13 @@ class InfoWindow
7
7
 
8
8
  include Curses
9
9
 
10
- attr_reader :win
10
+ attr_reader :win, :text
11
11
 
12
12
  TITLE = ' PatchMaster '
13
13
 
14
14
  def initialize(rows, cols, row, col)
15
15
  @win = Window.new(rows, cols, row, col)
16
+ @text = IO.read(CONTENTS)
16
17
  end
17
18
 
18
19
  def draw
@@ -21,7 +22,7 @@ class InfoWindow
21
22
  @win.addstr(TITLE)
22
23
  }
23
24
  @win.addstr("\n")
24
- IO.foreach(CONTENTS) { |line| @win.addstr(line) }
25
+ @text.each_line { |line| @win.addstr(line) }
25
26
  end
26
27
 
27
28
  def refresh
@@ -1,7 +1,7 @@
1
- j, down - next patch
2
- k, up - prev patch
3
- n, left - next song
4
- p, right - prev song
1
+ j, down, space - next patch
2
+ k, up - prev patch
3
+ n, left - next song
4
+ p, right - prev song
5
5
 
6
6
  g - goto song
7
7
  t - goto song list
@@ -11,5 +11,6 @@ F1 - help
11
11
 
12
12
  l - load
13
13
  s - save
14
+ e - edit
14
15
 
15
16
  q - quit
@@ -5,8 +5,10 @@ class ListWindow < PmWindow
5
5
 
6
6
  attr_reader :list
7
7
 
8
- def set_contents(title, list)
9
- @title, @list = title, list
8
+ # +curr_item_method_sym+ is a method symbol that is sent to
9
+ # PM::PatchMaster to obtain the current item so we can highlight it.
10
+ def set_contents(title, list, curr_item_method_sym)
11
+ @title, @list, @curr_item_method_sym = title, list, curr_item_method_sym
10
12
  draw
11
13
  end
12
14
 
@@ -14,11 +16,12 @@ class ListWindow < PmWindow
14
16
  super
15
17
  return unless @list
16
18
 
19
+ curr_item = PM::PatchMaster.instance.send(@curr_item_method_sym)
17
20
  @list.each_with_index do |thing, i|
18
21
  @win.setpos(i+1, 1)
19
- @win.attron(A_REVERSE) if thing == @list.curr
22
+ @win.attron(A_REVERSE) if thing == curr_item
20
23
  @win.addstr(make_fit(" #{thing.name} "))
21
- @win.attroff(A_REVERSE) if thing == @list.curr
24
+ @win.attroff(A_REVERSE) if thing == curr_item
22
25
  end
23
26
  end
24
27
 
@@ -1,13 +1,11 @@
1
1
  require 'curses'
2
2
  require 'singleton'
3
- %w(list patch info prompt).each { |w| require "patchmaster/app/#{w}_window" }
3
+ %w(list patch info trigger prompt).each { |w| require "patchmaster/app/#{w}_window" }
4
4
 
5
5
  module PM
6
6
 
7
7
  class Main
8
8
 
9
- DEBUG_FILE = '/tmp/pm_debug.txt'
10
-
11
9
  include Singleton
12
10
  include Curses
13
11
 
@@ -19,20 +17,15 @@ class Main
19
17
  @pm.no_midi!
20
18
  end
21
19
 
22
- # File must be a Ruby file that returns an array of song lists.
23
20
  def load(file)
24
21
  @pm.load(file)
25
22
  end
26
23
 
27
24
  def run
28
- if $DEBUG
29
- @debug_file = File.open(DEBUG_FILE, 'a')
30
- end
31
25
  @pm.start
32
26
  begin
33
27
  config_curses
34
28
  create_windows
35
- message("Welcome to PatchMaster")
36
29
 
37
30
  loop do
38
31
  begin
@@ -40,55 +33,68 @@ class Main
40
33
  ch = getch
41
34
  message("ch = #{ch}") if $DEBUG
42
35
  case ch
43
- when ?j, Key::DOWN
36
+ when 'j', Key::DOWN, ' '
44
37
  @pm.next_patch
45
- when ?k, Key::UP
38
+ when 'k', Key::UP
46
39
  @pm.prev_patch
47
- when ?n, Key::LEFT
40
+ when 'n', Key::LEFT
48
41
  @pm.next_song
49
- when ?p, Key::RIGHT
42
+ when 'p', Key::RIGHT
50
43
  @pm.prev_song
51
- when ?g
44
+ when 'g'
52
45
  name = PromptWindow.new('Go To Song', 'Go to song:').gets
53
46
  @pm.goto_song(name)
54
- when ?t
47
+ when 't'
55
48
  name = PromptWindow.new('Go To Song List', 'Go to Song List:').gets
56
49
  @pm.goto_song_list(name)
50
+ when 'e'
51
+ close_screen
52
+ @pm.edit
57
53
  when Key::F1
58
54
  help
59
- when 27 # escape
60
- @pm.panic
55
+ when 27 # "\e" doesn't work here
56
+ # Twice in a row sends individual note-off commands
57
+ message('Sending panic note off messages...')
58
+ @pm.panic(@prev_cmd == 27)
61
59
  message('Panic sent')
62
- when ?l
60
+ when 'l'
63
61
  file = PromptWindow.new('Load', 'Load file:').gets
64
62
  begin
65
63
  @pm.load(file)
64
+ message("Loaded #{file}")
66
65
  rescue => ex
67
66
  message(ex.to_s)
68
67
  end
69
- when ?s
68
+ when 's'
70
69
  file = PromptWindow.new('Save', 'Save into file:').gets
71
70
  begin
72
71
  @pm.save(file)
72
+ message("Saved #{file}")
73
73
  rescue => ex
74
74
  message(ex.to_s)
75
75
  end
76
- when ?q
76
+ when '?'
77
+ if $DEBUG
78
+ require 'pp'
79
+ out = ''
80
+ str = pp(@pm, out)
81
+ message("pm = #{out}")
82
+ end
83
+ when 'q'
77
84
  break
78
85
  end
86
+ @prev_cmd = ch
79
87
  rescue => ex
80
88
  message(ex.to_s)
81
- if $DEBUG
82
- @debug_file.puts caller.join("\n")
83
- end
89
+ @pm.debug caller.join("\n")
84
90
  end
85
91
  end
86
92
  ensure
93
+ clear
94
+ refresh
87
95
  close_screen
88
96
  @pm.stop
89
- if $DEBUG
90
- @debug_file.close
91
- end
97
+ @pm.close_debug_file
92
98
  end
93
99
  end
94
100
 
@@ -109,14 +115,22 @@ class Main
109
115
  sl_height = top_height - sls_height
110
116
 
111
117
  @song_lists_win = ListWindow.new(sls_height, top_width, 0, 0, nil)
112
- @song_lists_win.set_contents('Song Lists', @pm.song_lists)
113
118
  @song_list_win = ListWindow.new(sl_height, top_width, sls_height, 0, 'Song List')
114
119
  @song_win = ListWindow.new(top_height, top_width, 0, top_width, 'Song')
115
120
  @patch_win = PatchWindow.new(bot_height, cols(), top_height, 0, 'Patch')
116
121
  @message_win = Window.new(1, cols(), lines()-1, 0)
122
+ @message_win.scrollok(false)
123
+
124
+ third_height = top_height / 3
125
+ width = cols() - (top_width * 2) - 1
126
+ left = top_width * 2 + 1
117
127
 
118
- @info_win = InfoWindow.new(top_height, cols() - (top_width * 2) - 1, 0, top_width * 2 + 1)
128
+ @trigger_win = TriggerWindow.new(third_height, width, third_height * 2, left)
129
+ @trigger_win.draw
130
+
131
+ @info_win = InfoWindow.new(third_height * 2, width, 0, left)
119
132
  @info_win.draw
133
+
120
134
  end
121
135
 
122
136
  def help
@@ -131,28 +145,29 @@ class Main
131
145
  else
132
146
  $stderr.puts str
133
147
  end
134
- if $DEBUG
135
- @debug_file.puts "#{Time.now} #{str}"
136
- @debug_file.flush
137
- end
148
+ @pm.debug "#{Time.now} #{str}"
138
149
  end
139
150
 
140
151
  def refresh_all
141
152
  set_window_data
142
- [@song_lists_win, @song_list_win, @song_win, @patch_win].map(&:draw)
143
- [stdscr, @song_lists_win, @song_list_win, @song_win, @info_win, @patch_win, @message_win].map(&:refresh)
153
+ wins = [@song_lists_win, @song_list_win, @song_win, @patch_win, @info_win, @trigger_win]
154
+ wins.map(&:draw)
155
+ ([stdscr] + wins).map(&:refresh)
144
156
  end
145
157
 
146
158
  def set_window_data
147
- song_list = @pm.curr_song_list
148
- @song_list_win.set_contents(song_list.name, song_list.songs)
149
- song = song_list.curr_song
159
+ @song_lists_win.set_contents('Song Lists', @pm.song_lists, :song_list)
160
+
161
+ song_list = @pm.song_list
162
+ @song_list_win.set_contents(song_list.name, song_list.songs, :song)
163
+
164
+ song = @pm.song
150
165
  if song
151
- @song_win.set_contents(song.name, song.patches)
152
- patch = song.curr_patch
166
+ @song_win.set_contents(song.name, song.patches, :patch)
167
+ patch = @pm.patch
153
168
  @patch_win.patch = patch
154
169
  else
155
- @song_win.set_contents(nil, nil)
170
+ @song_win.set_contents(nil, nil, :patch)
156
171
  @patch_win.patch = nil
157
172
  end
158
173
  end
@@ -0,0 +1,27 @@
1
+ require 'curses'
2
+
3
+ module PM
4
+ class TriggerWindow < PmWindow
5
+
6
+ include Curses
7
+
8
+ def initialize(rows, cols, row, col)
9
+ super(rows, cols, row, col, nil)
10
+ @title = 'Triggers '
11
+ end
12
+
13
+ def draw
14
+ super
15
+ pm = PM::PatchMaster.instance
16
+ i = 0
17
+ pm.inputs.each do |sym, instrument|
18
+ instrument.triggers.each do |trigger|
19
+ @win.setpos(i+1, 1)
20
+ @win.addstr(make_fit(":#{sym} #{trigger.to_s}"))
21
+ i += 1
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
@@ -2,9 +2,10 @@ require 'patchmaster/consts'
2
2
 
3
3
  module PM
4
4
 
5
- # A Connection connects an InputDevice to an OutputDevice. Whenever MIDI
6
- # data arrives at the InputDevice it is optionally modified or filtered,
7
- # then the remaining modified data is sent to the OutputDevice.
5
+ # A Connection connects an InputInstrument to an OutputInstrument. Whenever
6
+ # MIDI data arrives at the InputInstrument it is optionally modified or
7
+ # filtered, then the remaining modified data is sent to the
8
+ # OutputInstrument.
8
9
  class Connection
9
10
 
10
11
  attr_accessor :input, :input_chan, :output, :output_chan,
@@ -24,19 +25,20 @@ class Connection
24
25
  end
25
26
 
26
27
  def start(start_bytes=nil)
27
- midi_out(@output, start_bytes) if start_bytes
28
- midi_out(@output, [PROGRAM_CHANGE + @output_chan, @pc_prog]) if pc?
28
+ midi_out(start_bytes) if start_bytes
29
+ midi_out([PROGRAM_CHANGE + @output_chan, @pc_prog]) if pc?
29
30
  @input.add_connection(self)
30
31
  end
31
32
 
32
- def stop
33
+ def stop(stop_bytes=nil)
34
+ midi_out(stop_bytes) if stop_bytes
33
35
  @input.remove_connection(self)
34
36
  end
35
37
 
36
38
  def accept_from_input?(bytes)
37
39
  return true if @input_chan == nil
38
- return true if bytes[0] >= 0xf0
39
- (bytes[0] & 0xf0) >= 0x80 && (bytes[0] & 0x0f) == @input_chan
40
+ return true unless bytes.channel?
41
+ bytes.note? && bytes.channel == @input_chan
40
42
  end
41
43
 
42
44
  # Returns true if the +@zone+ is nil (allowing all notes throught) or if
@@ -48,8 +50,8 @@ class Connection
48
50
  def midi_in(bytes)
49
51
  return unless accept_from_input?(bytes)
50
52
 
51
- # TODO handle running bytes
52
- high_nibble = bytes[0] & 0xf0
53
+ # TODO handle running bytes if needed
54
+ high_nibble = bytes.high_nibble
53
55
  case high_nibble
54
56
  when NOTE_ON, NOTE_OFF, POLY_PRESSURE
55
57
  return unless inside_zone?(bytes[1])
@@ -61,12 +63,12 @@ class Connection
61
63
 
62
64
  bytes = @filter.call(self, bytes) if @filter
63
65
  if bytes && bytes.size > 0
64
- midi_out(@output, bytes)
66
+ midi_out(bytes)
65
67
  end
66
68
  end
67
69
 
68
- def midi_out(device, bytes)
69
- device.midi_out(bytes)
70
+ def midi_out(bytes)
71
+ @output.midi_out(bytes)
70
72
  end
71
73
 
72
74
  def pc?
@@ -0,0 +1,174 @@
1
+ module PM
2
+
3
+ # A PM::Cursor knows the current PM::SongList, PM::Song, and PM::Patch, how
4
+ # to move between songs and patches, and how to find them given name
5
+ # regexes. A Cursor does not start/stop patches or manage connections.
6
+ class Cursor
7
+
8
+ attr_reader :song_list, :song, :patch
9
+
10
+ def initialize(pm)
11
+ @pm = pm
12
+ clear
13
+ end
14
+
15
+ # Set @song_list, @song, and @patch to +nil+.
16
+ def clear
17
+ @song_list = @song = @patch = nil
18
+ # Do not erase names saved by #mark.
19
+ end
20
+
21
+ # Set @song_list to All Songs, @song to first song, and
22
+ # @patch to song's first patch. Song and patch may be +nil+.
23
+ def init
24
+ @song_list = @pm.song_lists.first
25
+ @song = @song_list.songs.first
26
+ if @song
27
+ @patch = @song.patches.first
28
+ else
29
+ @patch = nil
30
+ end
31
+ end
32
+
33
+ def next_song
34
+ return unless @song_list
35
+ return if @song_list.songs.last == @song
36
+
37
+ @patch.stop if @patch
38
+ @song = @song_list.songs[@song_list.songs.index(@song) + 1]
39
+ @patch = @song.patches.first
40
+ @patch.start
41
+ end
42
+
43
+ def prev_song
44
+ return unless @song_list
45
+ return if @song_list.songs.first == @song
46
+
47
+ @patch.stop if @patch
48
+ @song = @song_list.songs[@song_list.songs.index(@song) - 1]
49
+ @patch = @song.patches.first
50
+ @patch.start
51
+ end
52
+
53
+ def next_patch
54
+ return unless @song
55
+ if @song.patches.last == @patch
56
+ next_song
57
+ elsif @patch
58
+ @patch.stop
59
+ @patch = @song.patches[@song.patches.index(@patch) + 1]
60
+ @patch.start
61
+ end
62
+ end
63
+
64
+ def prev_patch
65
+ return unless @song
66
+ if @song.patches.first == @patch
67
+ prev_song
68
+ elsif @patch
69
+ @patch.stop
70
+ @patch = @song.patches[@song.patches.index(@patch) - 1]
71
+ @patch.start
72
+ end
73
+ end
74
+
75
+ def goto_song(name_regex)
76
+ new_song_list = new_song = new_patch = nil
77
+ new_song = @song_list.find(name_regex) if @song_list
78
+ new_song = @@pm.all_songs.find(name_regex) unless new_song
79
+ new_patch = new_song ? new_song.patches.first : nil
80
+
81
+ if (new_song && new_song != @song) || # moved to new song
82
+ (new_song == @song && @patch != new_patch) # same song but not at same first patch
83
+
84
+ @patch.stop if @patch
85
+
86
+ if @song_list.songs.include?(new_song)
87
+ new_song_list = @song_list
88
+ else
89
+ # Not found in current song list. Switch to @pm.all_songs list.
90
+ new_song_list = @@pm.all_songs
91
+ end
92
+
93
+ @song_list = new_song_list
94
+ @song = new_song
95
+ @patch = new_patch
96
+ @patch.start
97
+ end
98
+ end
99
+
100
+ def goto_song_list(name_regex)
101
+ name_regex = Regexp.new(name_regex.to_s, true) # make case-insensitive
102
+ new_song_list = @pm.song_lists.detect { |song_list| song_list.name =~ name_regex }
103
+ return unless new_song_list
104
+
105
+ @song_list = new_song_list
106
+
107
+ new_song = @song_list.songs.first
108
+ new_patch = new_song ? new_song.patches.first : nil
109
+
110
+ if new_patch != @patch
111
+ @patch.stop if @patch
112
+ new_patch.start if new_patch
113
+ end
114
+ @song = new_song
115
+ @patch = new_patch
116
+ end
117
+
118
+ # Remembers the names of the current song list, song, and patch.
119
+ # Used by #restore.
120
+ def mark
121
+ @song_list_name = @song_list ? @song_list.name : nil
122
+ @song_name = @song ? @song.name : nil
123
+ @patch_name = @patch ? @patch.name : nil
124
+ end
125
+
126
+ # Using the names saved by #save, try to find them now.
127
+ #
128
+ # Since names can change we use Damerau-Levenshtein distance on lowercase
129
+ # versions of all strings.
130
+ def restore
131
+ return unless @song_list_name # will be nil on initial load
132
+
133
+ @song_list = find_nearest_match(@pm.song_lists, @song_list_name) || @pm.all_songs
134
+ @song = find_nearest_match(@song_list.songs, @song_name) || @song_list.songs.first
135
+ if @song
136
+ @patch = find_nearest_match(@song.patches, @patch_name) || @song.patches.first
137
+ else
138
+ @patch = nil
139
+ end
140
+ end
141
+
142
+ # List must contain objects that respond to #name. If +str+ is nil or
143
+ # +list+ is +nil+ or empty then +nil+ is returned.
144
+ def find_nearest_match(list, str)
145
+ return nil unless str && list && !list.empty?
146
+
147
+ str = str.downcase
148
+ distances = list.collect { |item| dameraulevenshtein(str, item.name.downcase) }
149
+ min_distance = distances.min
150
+ list[distances.index(distances.min)]
151
+ end
152
+
153
+ # https://gist.github.com/182759 (git://gist.github.com/182759.git)
154
+ # Referenced from http://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance
155
+ def dameraulevenshtein(seq1, seq2)
156
+ oneago = nil
157
+ thisrow = (1..seq2.size).to_a + [0]
158
+ seq1.size.times do |x|
159
+ twoago, oneago, thisrow = oneago, thisrow, [0] * seq2.size + [x + 1]
160
+ seq2.size.times do |y|
161
+ delcost = oneago[y] + 1
162
+ addcost = thisrow[y - 1] + 1
163
+ subcost = oneago[y - 1] + ((seq1[x] != seq2[y]) ? 1 : 0)
164
+ thisrow[y] = [delcost, addcost, subcost].min
165
+ if (x > 0 and y > 0 and seq1[x] == seq2[y-1] and seq1[x-1] == seq2[y] and seq1[x] != seq2[y])
166
+ thisrow[y] = [thisrow[y], twoago[y-2] + 1].min
167
+ end
168
+ end
169
+ end
170
+ return thisrow[seq2.size - 1]
171
+ end
172
+
173
+ end
174
+ end