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.
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