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 ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # usage: patchmaster [-i] [pm_file]
4
+ #
5
+ # Starts PatchMaster and optionally loads pm_file.
6
+ #
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
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.
11
+
12
+ require 'patchmaster'
13
+
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
@@ -0,0 +1,15 @@
1
+ require 'patchmaster/consts'
2
+ require 'patchmaster/predicates'
3
+ require 'patchmaster/song_list'
4
+ require 'patchmaster/song'
5
+ require 'patchmaster/patch'
6
+ require 'patchmaster/connection'
7
+ require 'patchmaster/filter'
8
+ require 'patchmaster/io'
9
+ require 'patchmaster/patchmaster'
10
+ require 'patchmaster/dsl'
11
+ require 'patchmaster/app/main'
12
+
13
+ def message(str)
14
+ PM::Main.instance.message(str)
15
+ end
@@ -0,0 +1,32 @@
1
+ require 'curses'
2
+
3
+ module PM
4
+ class InfoWindow
5
+
6
+ CONTENTS = File.join(File.dirname(__FILE__), 'info_window_contents.txt')
7
+
8
+ include Curses
9
+
10
+ attr_reader :win
11
+
12
+ TITLE = ' PatchMaster '
13
+
14
+ def initialize(rows, cols, row, col)
15
+ @win = Window.new(rows, cols, row, col)
16
+ end
17
+
18
+ def draw
19
+ @win.setpos(0, (@win.maxx() - TITLE.length) / 2)
20
+ @win.attron(A_REVERSE) {
21
+ @win.addstr(TITLE)
22
+ }
23
+ @win.addstr("\n")
24
+ IO.foreach(CONTENTS) { |line| @win.addstr(line) }
25
+ end
26
+
27
+ def refresh
28
+ @win.refresh
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ j, down - next patch
2
+ k, up - prev patch
3
+ n, left - next song
4
+ p, right - prev song
5
+
6
+ g - goto song
7
+ t - goto song list
8
+
9
+ ESC - panic
10
+ F1 - help
11
+
12
+ l - load
13
+ s - save
14
+
15
+ q - quit
@@ -0,0 +1,26 @@
1
+ require 'patchmaster/app/pm_window'
2
+
3
+ module PM
4
+ class ListWindow < PmWindow
5
+
6
+ attr_reader :list
7
+
8
+ def set_contents(title, list)
9
+ @title, @list = title, list
10
+ draw
11
+ end
12
+
13
+ def draw
14
+ super
15
+ return unless @list
16
+
17
+ @list.each_with_index do |thing, i|
18
+ @win.setpos(i+1, 1)
19
+ @win.attron(A_REVERSE) if thing == @list.curr
20
+ @win.addstr(make_fit(" #{thing.name} "))
21
+ @win.attroff(A_REVERSE) if thing == @list.curr
22
+ end
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,161 @@
1
+ require 'curses'
2
+ require 'singleton'
3
+ %w(list patch info prompt).each { |w| require "patchmaster/app/#{w}_window" }
4
+
5
+ module PM
6
+
7
+ class Main
8
+
9
+ DEBUG_FILE = '/tmp/pm_debug.txt'
10
+
11
+ include Singleton
12
+ include Curses
13
+
14
+ def initialize
15
+ @pm = PatchMaster.instance
16
+ end
17
+
18
+ def no_midi!
19
+ @pm.no_midi!
20
+ end
21
+
22
+ # File must be a Ruby file that returns an array of song lists.
23
+ def load(file)
24
+ @pm.load(file)
25
+ end
26
+
27
+ def run
28
+ if $DEBUG
29
+ @debug_file = File.open(DEBUG_FILE, 'a')
30
+ end
31
+ @pm.start
32
+ begin
33
+ config_curses
34
+ create_windows
35
+ message("Welcome to PatchMaster")
36
+
37
+ loop do
38
+ begin
39
+ refresh_all
40
+ ch = getch
41
+ message("ch = #{ch}") if $DEBUG
42
+ case ch
43
+ when ?j, Key::DOWN
44
+ @pm.next_patch
45
+ when ?k, Key::UP
46
+ @pm.prev_patch
47
+ when ?n, Key::LEFT
48
+ @pm.next_song
49
+ when ?p, Key::RIGHT
50
+ @pm.prev_song
51
+ when ?g
52
+ name = PromptWindow.new('Go To Song', 'Go to song:').gets
53
+ @pm.goto_song(name)
54
+ when ?t
55
+ name = PromptWindow.new('Go To Song List', 'Go to Song List:').gets
56
+ @pm.goto_song_list(name)
57
+ when Key::F1
58
+ help
59
+ when 27 # escape
60
+ @pm.panic
61
+ message('Panic sent')
62
+ when ?l
63
+ file = PromptWindow.new('Load', 'Load file:').gets
64
+ begin
65
+ @pm.load(file)
66
+ rescue => ex
67
+ message(ex.to_s)
68
+ end
69
+ when ?s
70
+ file = PromptWindow.new('Save', 'Save into file:').gets
71
+ begin
72
+ @pm.save(file)
73
+ rescue => ex
74
+ message(ex.to_s)
75
+ end
76
+ when ?q
77
+ break
78
+ end
79
+ rescue => ex
80
+ message(ex.to_s)
81
+ if $DEBUG
82
+ @debug_file.puts caller.join("\n")
83
+ end
84
+ end
85
+ end
86
+ ensure
87
+ close_screen
88
+ @pm.stop
89
+ if $DEBUG
90
+ @debug_file.close
91
+ end
92
+ end
93
+ end
94
+
95
+ def config_curses
96
+ init_screen
97
+ cbreak # unbuffered input
98
+ noecho # do not show typed keys
99
+ stdscr.keypad(true) # enable arrow keys
100
+ curs_set(0) # cursor: 0 = invisible, 1 = normal
101
+ end
102
+
103
+ def create_windows
104
+ top_height = (lines() - 1) * 2 / 3
105
+ bot_height = (lines() - 1) - top_height
106
+ top_width = cols() / 3
107
+
108
+ sls_height = top_height / 3
109
+ sl_height = top_height - sls_height
110
+
111
+ @song_lists_win = ListWindow.new(sls_height, top_width, 0, 0, nil)
112
+ @song_lists_win.set_contents('Song Lists', @pm.song_lists)
113
+ @song_list_win = ListWindow.new(sl_height, top_width, sls_height, 0, 'Song List')
114
+ @song_win = ListWindow.new(top_height, top_width, 0, top_width, 'Song')
115
+ @patch_win = PatchWindow.new(bot_height, cols(), top_height, 0, 'Patch')
116
+ @message_win = Window.new(1, cols(), lines()-1, 0)
117
+
118
+ @info_win = InfoWindow.new(top_height, cols() - (top_width * 2) - 1, 0, top_width * 2 + 1)
119
+ @info_win.draw
120
+ end
121
+
122
+ def help
123
+ message("Help: not yet implemented")
124
+ end
125
+
126
+ def message(str)
127
+ if @message_win
128
+ @message_win.clear
129
+ @message_win.addstr(str)
130
+ @message_win.refresh
131
+ else
132
+ $stderr.puts str
133
+ end
134
+ if $DEBUG
135
+ @debug_file.puts "#{Time.now} #{str}"
136
+ @debug_file.flush
137
+ end
138
+ end
139
+
140
+ def refresh_all
141
+ 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)
144
+ end
145
+
146
+ 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
150
+ if song
151
+ @song_win.set_contents(song.name, song.patches)
152
+ patch = song.curr_patch
153
+ @patch_win.patch = patch
154
+ else
155
+ @song_win.set_contents(nil, nil)
156
+ @patch_win.patch = nil
157
+ end
158
+ end
159
+
160
+ end
161
+ end
@@ -0,0 +1,61 @@
1
+ require 'patchmaster/app/pm_window'
2
+
3
+ module PM
4
+ class PatchWindow < PmWindow
5
+
6
+ attr_reader :patch
7
+
8
+ def patch=(patch)
9
+ @title = patch ? patch.name : nil
10
+ @patch = patch
11
+ draw
12
+ end
13
+
14
+ def draw
15
+ super
16
+ @win.setpos(1, 1)
17
+ draw_headers
18
+ return unless @patch
19
+
20
+ max_len = @win.maxx - 3 # minus 2 for borders
21
+ @patch.connections.each_with_index do |connection, i|
22
+ @win.setpos(i+2, 1)
23
+ draw_connection(connection, max_len)
24
+ end
25
+ end
26
+
27
+ def draw_headers
28
+ @win.attron(A_REVERSE) {
29
+ str = " Input Chan | Output Chan | Prog | Zone | Xpose | Filter"
30
+ str << ' ' * (@win.maxx - 2 - str.length)
31
+ @win.addstr(str)
32
+ }
33
+ end
34
+
35
+ def draw_connection(connection, max_len)
36
+ str = " #{'%16s' % connection.input.name}"
37
+ str << " #{connection.input_chan ? ('%2d' % (connection.input_chan+1)) : ' '} |"
38
+ str << " #{'%16s' % connection.output.name}"
39
+ str << " #{'%2d' % (connection.output_chan+1)} |"
40
+ str << if connection.pc?
41
+ " #{'%3d' % connection.pc_prog} |"
42
+ else
43
+ " |"
44
+ end
45
+ str << if connection.zone
46
+ " #{'%3s' % connection.note_num_to_name(connection.zone.begin)}" +
47
+ " - #{'%3s' % connection.note_num_to_name(connection.zone.end)} |"
48
+ else
49
+ ' |'
50
+ end
51
+ str << if connection.xpose && connection.xpose > 0
52
+ " #{'%2d' % connection.xpose.to_i} |"
53
+ else
54
+ " |"
55
+ end
56
+ str << " #{connection.filter}"
57
+ @win.addstr(make_fit(str))
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,41 @@
1
+ require 'curses'
2
+
3
+ module PM
4
+ class PmWindow
5
+
6
+ include Curses
7
+
8
+ attr_reader :win, :title_prefix
9
+ attr_accessor :title
10
+
11
+ # If title is nil then list's name will be used
12
+ def initialize(rows, cols, row, col, title_prefix)
13
+ @win = Window.new(rows, cols, row, col)
14
+ @title_prefix = title_prefix
15
+ @max_contents_len = @win.maxx - 3 # 2 for borders
16
+ end
17
+
18
+ def refresh
19
+ @win.refresh
20
+ end
21
+
22
+ def draw
23
+ @win.clear
24
+ @win.box(?|, ?-)
25
+ return unless @title_prefix || @title
26
+
27
+ @win.setpos(0, 1)
28
+ @win.attron(A_REVERSE) {
29
+ @win.addch(' ')
30
+ @win.addstr("#{@title_prefix}: ") if @title_prefix
31
+ @win.addstr(@title) if @title
32
+ @win.addch(' ')
33
+ }
34
+ end
35
+
36
+ def make_fit(str)
37
+ str = str[0..@max_contents_len] if str.length > @max_contents_len
38
+ str
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ require 'curses'
2
+
3
+ module PM
4
+ class PromptWindow
5
+
6
+ MAX_WIDTH = 30
7
+
8
+ include Curses
9
+
10
+ def initialize(title, prompt)
11
+ @title, @prompt = title, prompt
12
+ width = cols() / 2
13
+ width = MAX_WIDTH if width > MAX_WIDTH
14
+ @win = Window.new(4, width, lines() / 3, (cols() - width) / 2)
15
+ end
16
+
17
+ def gets
18
+ draw
19
+ str = read_string
20
+ cleanup
21
+ str
22
+ end
23
+
24
+ def draw
25
+ @win.box(?|, ?-)
26
+ @win.setpos(0, 1)
27
+ @win.attron(A_REVERSE) {
28
+ @win.addstr(" #@title ")
29
+ }
30
+
31
+ @win.setpos(1, 1)
32
+ @win.addstr(@prompt)
33
+
34
+ @win.setpos(2, 1)
35
+ @win.attron(A_REVERSE) {
36
+ @win.addstr(' ' * (@win.maxx() - 2))
37
+ }
38
+
39
+ @win.setpos(2, 1)
40
+ @win.refresh
41
+ end
42
+
43
+ def read_string
44
+ nocbreak
45
+ echo
46
+ curs_set(1)
47
+ str = nil
48
+ @win.attron(A_REVERSE) {
49
+ str = @win.getstr
50
+ }
51
+ curs_set(0)
52
+ noecho
53
+ cbreak
54
+ str
55
+ end
56
+
57
+ def cleanup
58
+ @win.close
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,90 @@
1
+ require 'patchmaster/consts'
2
+
3
+ module PM
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.
8
+ class Connection
9
+
10
+ attr_accessor :input, :input_chan, :output, :output_chan,
11
+ :pc_prog, :zone, :xpose, :filter
12
+
13
+ # If input_chan is nil than all messages from input will be sent to
14
+ # output.
15
+ #
16
+ # All channels (input_chan, output_chan, etc.) are 1-based here but are
17
+ # turned into 0-based channels for later use.
18
+ def initialize(input, input_chan, output, output_chan, filter=nil, opts={})
19
+ @input, @input_chan, @output, @output_chan, @filter = input, input_chan, output, output_chan, filter
20
+ @pc_prog, @zone, @xpose = opts[:pc_prog], opts[:zone], opts[:xpose]
21
+
22
+ @input_chan -= 1 if @input_chan
23
+ @output_chan -= 1 if @output_chan
24
+ end
25
+
26
+ 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?
29
+ @input.add_connection(self)
30
+ end
31
+
32
+ def stop
33
+ @input.remove_connection(self)
34
+ end
35
+
36
+ def accept_from_input?(bytes)
37
+ return true if @input_chan == nil
38
+ return true if bytes[0] >= 0xf0
39
+ (bytes[0] & 0xf0) >= 0x80 && (bytes[0] & 0x0f) == @input_chan
40
+ end
41
+
42
+ # Returns true if the +@zone+ is nil (allowing all notes throught) or if
43
+ # +@zone+ is a Range and +note+ is inside +@zone+.
44
+ def inside_zone?(note)
45
+ @zone == nil || @zone.include?(note)
46
+ end
47
+
48
+ def midi_in(bytes)
49
+ return unless accept_from_input?(bytes)
50
+
51
+ # TODO handle running bytes
52
+ high_nibble = bytes[0] & 0xf0
53
+ case high_nibble
54
+ when NOTE_ON, NOTE_OFF, POLY_PRESSURE
55
+ return unless inside_zone?(bytes[1])
56
+ bytes[0] = high_nibble + @output_chan
57
+ bytes[1] = ((bytes[1] + @xpose) & 0xff) if @xpose
58
+ when CONTROLLER, PROGRAM_CHANGE, CHANNEL_PRESSURE, PITCH_BEND
59
+ bytes[0] = high_nibble + @output_chan
60
+ end
61
+
62
+ bytes = @filter.call(self, bytes) if @filter
63
+ if bytes && bytes.size > 0
64
+ midi_out(@output, bytes)
65
+ end
66
+ end
67
+
68
+ def midi_out(device, bytes)
69
+ device.midi_out(bytes)
70
+ end
71
+
72
+ def pc?
73
+ @pc_prog != nil
74
+ end
75
+
76
+ def note_num_to_name(n)
77
+ oct = (n / 12) - 1
78
+ note = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'][n % 12]
79
+ "#{note}#{oct}"
80
+ end
81
+
82
+ def to_s
83
+ str = "#{@input.name} ch #{@input_chan ? @input_chan+1 : 'all'} -> #{@output.name} ch #{@output_chan+1}"
84
+ str << "; pc #@pc_prog" if pc?
85
+ str << "; xpose #@xpose" if @xpose
86
+ str << "; zone #{note_num_to_name(@zone.begin)}..#{note_num_to_name(@zone.end)}" if @zone
87
+ end
88
+ end
89
+
90
+ end # PM