ramekin 0.0.5b

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.
@@ -0,0 +1,288 @@
1
+ module Ramekin
2
+ class Renderer
3
+ include Error::Helpers
4
+
5
+ def self.render(fname, track, &b)
6
+ new(fname, track).render(&b)
7
+ end
8
+
9
+ def initialize(filename, track)
10
+ @filename = File.basename(filename)
11
+ @track = track
12
+
13
+ @channel = 0
14
+ @tick = 0
15
+ @octave = 4
16
+ @volume = 0xFF
17
+
18
+ # TODO: add @smwxxx special instruments and automatically
19
+ # optimize out unused sfx samples
20
+ @instrument_index = {}
21
+
22
+ (0...30).each do |i|
23
+ @instrument_index[i.to_s] = i
24
+ end
25
+
26
+ @instrument_index['smwflute'] = 0
27
+ @instrument_index['smwstring'] = 1
28
+ @instrument_index['smwglock'] = 2
29
+ @instrument_index['smwmarimba'] = 3
30
+ @instrument_index['smwcello'] = 4
31
+ @instrument_index['smwsteelguitar'] = 5
32
+ @instrument_index['smwtrumpet'] = 6
33
+ @instrument_index['smwsteeldrum'] = 7
34
+ @instrument_index['smwacousticbass'] = 8
35
+ @instrument_index['smwpiano'] = 9
36
+ @instrument_index['smwsnare'] = 10
37
+ @instrument_index['smwstring2'] = 11
38
+ @instrument_index['smwbongo'] = 12
39
+ @instrument_index['smwep'] = 13
40
+ @instrument_index['smwslapbass'] = 14
41
+ @instrument_index['smworchhit'] = 15
42
+ @instrument_index['smwharp'] = 16
43
+ @instrument_index['smwdistguitar'] = 17
44
+ @instrument_index['smwkick'] = 21
45
+ @instrument_index['smwhat'] = 22
46
+ @instrument_index['smwshaker'] = 23
47
+ @instrument_index['smwwoodblock'] = 24
48
+ @instrument_index['smwhiwoodblock'] = 25
49
+ @instrument_index['smwdrums'] = 28
50
+ @instrument_index['smwpower'] = 29
51
+
52
+ # optional default octaves for instrument switching
53
+ @default_octaves = Hash.new(4)
54
+ end
55
+
56
+ def render(&b)
57
+ return render_string unless block_given?
58
+
59
+ render_preamble(&b)
60
+
61
+ @track.channels.each { |c| render_channel(c, &b) }
62
+ end
63
+
64
+ def render_preamble(&b)
65
+ m = @track.meta
66
+ yield "#SPC\n{\n"
67
+ yield " #title #{m.title.value.inspect}\n" if m.title
68
+ yield " #author #{m.author.value.inspect}\n" if m.author
69
+ yield " #game #{m.game.value.inspect}\n" if m.game
70
+ yield " #comment #{m.comment.value.inspect}\n" if m.comment
71
+ yield "}\n\n"
72
+
73
+ yield "; generated from #{@filename} by Ramekin\n"
74
+ yield "; https://codeberg.org/jneen/ramekin\n\n"
75
+
76
+ # TODO
77
+ yield "#amk 2\n\n"
78
+
79
+ if m.instruments.any?
80
+ yield "#path #{@filename.chomp('.rmk').inspect}\n"
81
+ yield "#samples {\n"
82
+ m.sample_groups.each do |group|
83
+ yield " ##{group.value}\n"
84
+ end
85
+
86
+ m.instruments.map(&:sample_name).sort.uniq.each do |sample|
87
+ yield " #{sample.inspect}\n"
88
+ end
89
+ yield "}\n\n"
90
+
91
+ yield "#instruments {\n"
92
+ m.instruments.each_with_index do |inst, i|
93
+ @instrument_index[inst.name.value] = 30 + i
94
+ @default_octaves[inst.name.value] = inst.octave
95
+ yield " #{inst.to_amk}\n"
96
+ end
97
+ yield "}\n\n"
98
+ end
99
+
100
+ yield "t#{tempo_of(m.tempo)} ; main tempo (0-60)\n" if m.tempo
101
+ yield "w#{m.volume.value} ; main volume (0-255)\n" if m.volume
102
+ yield "l16"
103
+ # binding.pry
104
+ end
105
+
106
+ def tempo_of(el)
107
+ case el.type
108
+ when :t then el.value
109
+ when :bpm then (el.value.to_i * 256 / 625.0).round
110
+ end
111
+ end
112
+
113
+ def next!
114
+ if @peek
115
+ return @peek.tap { @peek = nil }
116
+ end
117
+
118
+ @current_channel.next
119
+ end
120
+
121
+ def peek
122
+ @peek ||= @current_channel.next
123
+ end
124
+
125
+ def render_channel(channel, &b)
126
+ @tick = 0
127
+ yield "\n\n"
128
+ @current_channel = channel.each
129
+ loop do
130
+ @current = el = next!
131
+
132
+ case el
133
+ when NoteEvent
134
+ old_tick = @tick
135
+ @tick += el.ticks
136
+ if (old_tick-1) / 192 != (@tick-1) / 192
137
+ yield "\n"
138
+ end
139
+ yield el.to_amk(@octave)
140
+ @octave = el.octave unless el.rest?
141
+ when MacroDefinition
142
+ # pass
143
+ when LegatoStart, LegatoLastNote
144
+ yield el.to_amk
145
+ when Instrument
146
+ yield "@#{@instrument_index[el.name.value]}"
147
+ when Token
148
+ render_token(el, &b)
149
+ else
150
+ error! "unexpected element type #{el.inspect}"
151
+ end
152
+ end
153
+ end
154
+
155
+ def render_token(token, &b)
156
+ case token.type
157
+ when :t, :bpm
158
+ yield "t#{tempo_of(token)}"
159
+ when :directive
160
+ render_directive(token, &b)
161
+ when :channel
162
+ yield "##{token.value}\n\n"
163
+ when :hex
164
+ yield "$#{token.value.downcase} "
165
+ when :slash
166
+ yield "\n/\n"
167
+ # reset tick counter for newlines
168
+ @tick = 0
169
+ when :transpose
170
+ interval = token.value.to_i
171
+
172
+ if interval < 0
173
+ interval = 0x80 - interval
174
+ end
175
+
176
+ yield sprintf("$fa$02$%02x", token.value.to_i)
177
+ when :instrument
178
+ case token.value
179
+ when /\A\d+\z/ then yield "@#{token.value}"
180
+ else
181
+ @octave = nil
182
+ unless @instrument_index.key?(token.value)
183
+ error! "undeclared instrument @#{token.value}"
184
+ end
185
+
186
+ yield "@#{@instrument_index[token.value]}"
187
+ end
188
+ when :v
189
+ yield "v#{token.values.compact.join(',')}"
190
+ @volume = token.values[0].to_i
191
+ when :relv
192
+ relvol, duration = token.values
193
+ yield "v#{[@volume + relvol.to_i, duration].compact.join(',')}"
194
+ when :adsr
195
+ vals = token.value.split(',').map { |x| x.to_i(16) }
196
+ error! 'invalid #adsr, expected 4 arguments' unless vals.size == 4
197
+
198
+ a, d, s, r = vals
199
+ error! 'invalid attack (must be 0-F)' unless (0..15).include?(a)
200
+ error! 'invalid decay (must be 0-7)' unless (0..7).include?(d)
201
+ error! 'invalid sustain (must be 0-7)' unless (0..7).include?(s)
202
+ error! 'invalid release (must be 0-1F)' unless (0..31).include?(r)
203
+
204
+ yield sprintf("$ed$%02x$%02x", (7-d) * 16 + (15-a), s * 32 + (31-r))
205
+ when :y
206
+ yield "y#{token.value}"
207
+ when :rely
208
+ token.value =~ /\A([LRC])(\d*)\z/
209
+ pan = case $1
210
+ when 'L' then 10 + $2.to_i
211
+ when 'R' then 10 - $2.to_i
212
+ when 'C' then 10
213
+ end
214
+
215
+ yield "y#{pan}"
216
+
217
+ when :p
218
+ # it's free aram
219
+ if token.value == '0,0'
220
+ yield '$df'
221
+ else
222
+ yield "p#{token.value} "
223
+ end
224
+ when :superloop
225
+ yield " [[ "
226
+ when :loop
227
+ if token.value
228
+ yield " (#{token.value})[ "
229
+ else
230
+ yield " [ "
231
+ end
232
+ when :loop_end
233
+ yield " ]#{token.value} "
234
+ when :loop_call
235
+ yield " (#{token.values[0]})#{token.values[1]} "
236
+ when :star
237
+ yield "*#{token.value}"
238
+ when :superloop_end
239
+ yield " ]]#{token.value} "
240
+ when :amp
241
+ yield '&'
242
+
243
+ # triplets are handled in NoteAggregator
244
+ when :lbrace, :rbrace
245
+ # pass
246
+ when :rbrace
247
+ yield '}'
248
+
249
+ else
250
+ error! "unexpected token type: #{token.type}"
251
+ end
252
+ end
253
+
254
+ def render_directive(token)
255
+ case token.value
256
+ when 'SPC'
257
+ # pass
258
+ when 'bend'
259
+ note = next!
260
+
261
+ error! '#bend must be followed by a note' unless NoteEvent === note
262
+ error! 'cannot #bend to a rest' if note.rest?
263
+
264
+ ticks = sprintf("%02x", note.ticks)
265
+ yield "$dd$#{ticks}$#{ticks}#{note.octave_amk}#{note.note.value}"
266
+
267
+ case peek
268
+ when NoteEvent
269
+ peek.extensions.concat(note.extensions)
270
+ else
271
+ yield "^=#{note.ticks}"
272
+ end
273
+
274
+ @octave = note.octave
275
+ when 'legato'
276
+ yield '$f4$01'
277
+ else
278
+ error! "unexpected directive ##{token.value}"
279
+ end
280
+ end
281
+
282
+ def render_string
283
+ out = StringIO.new
284
+ render { |chunk| out << chunk }
285
+ out.string
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,296 @@
1
+ require 'uri'
2
+ require 'open-uri'
3
+ require 'json'
4
+
5
+ module Ramekin
6
+ class APIEndpoint
7
+ BASE = 'https://www.smwcentral.net/ajax.php'
8
+
9
+ def initialize(**params)
10
+ @params = {}
11
+ params.each { |k, v| self[k] = v }
12
+ end
13
+
14
+ def self.get!(**params)
15
+ resp = URI.open(new(**params).to_uri)
16
+ binding.pry unless resp.status == %w(200 OK)
17
+ JSON.parse(resp.read)
18
+ end
19
+
20
+ def []=(k, v)
21
+ case v
22
+ when Hash
23
+ v.each do |subk, subv|
24
+ self["#{k}[#{subk}]"] = subv
25
+ end
26
+ when Array
27
+ v.each_with_index do |subv, i|
28
+ self["#{k}[#{i}]"] = subv
29
+ end
30
+ when String, Numeric
31
+ @params[k] = v
32
+ end
33
+ end
34
+
35
+ def to_uri
36
+ URI(BASE).tap do |uri|
37
+ uri.query = URI.encode_www_form(@params)
38
+ end
39
+ end
40
+
41
+ def to_s
42
+ to_uri.to_s
43
+ end
44
+ end
45
+
46
+ REPO_DIR = File.expand_path('~/.ramekin')
47
+ FileUtils.mkdir_p(REPO_DIR)
48
+
49
+ class SamplePack
50
+ def self.smwc_each(&b)
51
+ params = { a: 'getsectionlist', s: 'brrsamples' }
52
+ page = APIEndpoint.get!(**params)
53
+ page['data'].each { |e| yield new(e) }
54
+
55
+ (2..page['last_page']).each do |n|
56
+ page = APIEndpoint.get!(n: n, **params)
57
+ page['data'].each { |e| yield new(e) }
58
+ end
59
+ end
60
+
61
+ def self.find(name)
62
+ each do |pack|
63
+ return pack if pack.name == name
64
+ end
65
+
66
+ return nil
67
+ end
68
+
69
+ extend Enumerable
70
+ def self.each(&b)
71
+ return enum_for(:each) unless block_given?
72
+
73
+ Dir.chdir Ramekin.config.packages_dir do
74
+ Dir.entries('.').sort.each do |subd|
75
+ next unless /\A\d+\z/ =~ subd
76
+ Dir.chdir(subd) do
77
+ yield new(JSON.load_file(".meta.json"))
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ def initialize(data)
84
+ @meta = data
85
+ end
86
+
87
+ def name
88
+ @meta['name'] or raise 'no name'
89
+ end
90
+
91
+ def id
92
+ @meta['id'].to_i or raise 'no id'
93
+ end
94
+
95
+ def url
96
+ "https://www.smwcentral.net/?p=section&a=details&id=#{id}"
97
+ end
98
+
99
+ def authors
100
+ (@meta['authors'] || []).map { |a| a && a['name'] }.compact
101
+ end
102
+
103
+ def find(path)
104
+ path = path.chomp('.brr')
105
+ File.join(prefix_dir, "#{path}.brr")
106
+ end
107
+
108
+ def tunings_for(path)
109
+ rel = Pathname.new(find(path)).relative_path_from(dir).to_s
110
+ (tunings[rel] || []).map(&:last)
111
+ end
112
+
113
+ def download_url
114
+ @meta['download_url'] or raise 'no download_url'
115
+ end
116
+
117
+ def shortest_prefix(paths)
118
+ paths = paths.map { |x| x.split('/') }.sort_by(&:size)
119
+
120
+ prefix = []
121
+ loop do
122
+ break if paths[0].empty?
123
+ init = paths.map(&:shift).uniq
124
+ break if init.size > 1
125
+ prefix << init[0]
126
+ end
127
+
128
+ prefix.join('/')
129
+ end
130
+
131
+ TUNING_RE = %r(
132
+ "(.*?[.]brr)" \s*
133
+ [$](\h\h) \s*
134
+ [$](\h\h) \s*
135
+ [$](\h\h) \s*
136
+ [$](\h\h) \s*
137
+ [$](\h\h)
138
+ )x
139
+
140
+ def find_tunings(name_re)
141
+ raw = []
142
+ Dir.glob('**/*.txt').sort.each do |entry|
143
+ next unless File.basename(entry).downcase =~ name_re
144
+
145
+ contents = File.read(entry, encoding: 'binary')
146
+
147
+ contents.gsub!(/;.*$/, '')
148
+ # windows paths...
149
+ contents.gsub!(/\\/, '/')
150
+
151
+ # contents.scan /#path\s*"(.*?)"/ do |(path)|
152
+ # paths << path
153
+ # end
154
+
155
+ contents.scan TUNING_RE do |(path, *hexes)|
156
+ raw << [entry, path, hexes]
157
+ end
158
+ end
159
+
160
+ out = Hash.new {|h,k| h[k] = []}
161
+ raw.each do |tuning_path, name, hexes|
162
+ out[File.basename(name).downcase] << [tuning_path, name, hexes]
163
+ end
164
+
165
+ out
166
+ end
167
+
168
+ def index_packs
169
+ paths = []
170
+ raw_tunings = []
171
+
172
+ patterns_tunings = find_tunings(/\A!patterns[.]txt\z/)
173
+ other_tunings = find_tunings(/\Atuning/)
174
+ misc_tunings = find_tunings(//)
175
+
176
+ @brrs = Dir.glob('**/*.brr')
177
+ @brrs.sort!
178
+ @brrs.uniq!
179
+ @prefix = shortest_prefix(@brrs.map(&File.method(:dirname)))
180
+
181
+ @tunings = {}
182
+ @brrs.each do |brr|
183
+ base = File.basename(brr).downcase
184
+ next if base == 'empty.brr'
185
+
186
+ candidates = patterns_tunings[base]
187
+ candidates = other_tunings[base] if candidates.empty?
188
+ candidates = misc_tunings[base] if candidates.empty?
189
+
190
+ candidates = candidates.uniq { |_,_,h| h }
191
+
192
+ # if there is more than one candidate, prioritize the one that's closest
193
+ if candidates.size >= 2
194
+ candidates.sort_by! { |path, _, _| -shortest_prefix([brr, path]).size }
195
+ end
196
+
197
+ @tunings[brr] = candidates.map { |p, _, h| [p, h] }
198
+ end
199
+
200
+ File.write('.index.json', JSON.dump(
201
+ prefix: @prefix,
202
+ brrs: @brrs,
203
+ tunings: @tunings,
204
+ ))
205
+
206
+ @cached_index = nil
207
+ end
208
+
209
+ def cached_index
210
+ @cached_index ||= JSON.load_file("#{dir}/.index.json")
211
+ end
212
+
213
+ def prefix
214
+ cached_index['prefix']
215
+ end
216
+
217
+ def unprefix(path)
218
+ Pathname.new(dir).join(path).relative_path_from(prefix_dir).to_s
219
+ end
220
+
221
+ def unprefixed_brrs
222
+ brrs.map(&method(:unprefix))
223
+ end
224
+
225
+ def brrs
226
+ cached_index['brrs']
227
+ end
228
+
229
+ def tunings
230
+ cached_index['tunings']
231
+ end
232
+
233
+ def prefix_dir
234
+ File.join(dir, prefix)
235
+ end
236
+
237
+ def self.download_all!
238
+ smwc_each(&:download)
239
+ each(&:index_packs)
240
+ end
241
+
242
+ def needs_download?
243
+ return true unless exists?
244
+ @meta['time'] > cached_json['time']
245
+ end
246
+
247
+ def cached_json
248
+ @cached_json ||= JSON.load_file(meta_file)
249
+ rescue Errno::ENOENT
250
+ nil
251
+ end
252
+
253
+ def exists?
254
+ File.exist?(meta_file) && !!cached_json && !!cached_json['id']
255
+ end
256
+
257
+ def meta_file
258
+ "#{dir}/.meta.json"
259
+ end
260
+
261
+ def dir
262
+ "#{Ramekin.config.packages_dir}/#{id}"
263
+ end
264
+
265
+ def download
266
+ unless needs_download?
267
+ $stderr.puts "skipping: #{name}:#{id}"
268
+ return
269
+ end
270
+
271
+ download!
272
+ end
273
+
274
+ def download!
275
+ $stderr.puts "downloading to #{dir}"
276
+ FileUtils.rm_rf(dir, secure: true)
277
+ FileUtils.mkdir_p(dir)
278
+
279
+ Dir.chdir(dir) do
280
+ zip = URI.open(download_url).read
281
+ Zip::File.open_buffer(zip).each do |entry|
282
+ # look of disapproval
283
+ next if entry.directory?
284
+ next if entry.name =~ /__MACOSX/
285
+ next unless File.expand_path(name).start_with?(File.expand_path('.'))
286
+
287
+ $stderr.puts " -> #{entry.name}"
288
+ FileUtils.mkdir_p(File.dirname(entry.name))
289
+ entry.extract(entry.name)
290
+ end
291
+ end
292
+
293
+ File.write(meta_file, JSON.dump(@meta))
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,122 @@
1
+ module Ramekin
2
+ module SPCPlayer
3
+ def self.make_instance
4
+ config = Ramekin.config.spc_player
5
+ unless config == '__auto__'
6
+ return CustomSPCPlayer.new(File.expand_path(config, HOME))
7
+ end
8
+
9
+ return WindowsSPCPlayer.new if Ramekin.config.windows?
10
+ NormalSPCPlayer.new
11
+ end
12
+
13
+ def self.instance(*a)
14
+ @instance ||= make_instance
15
+ end
16
+
17
+ class SPCPlayer
18
+ include Util
19
+
20
+ def setup_ok?
21
+ true
22
+ end
23
+
24
+ def setup!
25
+ # pass
26
+ end
27
+
28
+ end
29
+
30
+ class WindowsSPCPlayer < SPCPlayer
31
+ def initialize
32
+ @spcplay_dir = "#{HOME}/spcplay"
33
+ @smwc_url = "https://dl.smwcentral.net/35673/spcplay-2.20.1.8272.zip"
34
+ end
35
+
36
+ def setup_ok?
37
+ File.exist?("#@spcplay_dir/spcplay.exe")
38
+ end
39
+
40
+ def play(fname)
41
+ fname = File.expand_path(fname)
42
+ Dir.chdir(@spcplay_dir) do
43
+ sys "spcplay.exe", fname
44
+ end
45
+ end
46
+
47
+ def setup!
48
+ return if setup_ok?
49
+
50
+ FileUtils.mkdir_p(@spcplay_dir)
51
+
52
+ Dir.chdir @spcplay_dir do
53
+ $stderr.puts "extracting spcplay from #@smwc_url"
54
+ $stderr.puts "into #$spcplay_dir"
55
+ zip = URI.open(@smwc_url).read
56
+ unpack_zip_here(zip) do |entry|
57
+ $stderr.puts " -> #{entry}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ class NormalSPCPlayer < SPCPlayer
64
+ def play(fname)
65
+ sys "#@spct_dir/spct", 'play', fname
66
+ end
67
+
68
+ def render(fname, outfile, seconds=nil)
69
+ args = ["#@spct_dir/spct", 'render', fname, outfile]
70
+ args.concat(['-s', seconds.to_s]) if seconds
71
+ sys(*args)
72
+ end
73
+
74
+ def initialize
75
+ @spct_dir = "#{HOME}/spct"
76
+ end
77
+
78
+ def setup_ok?
79
+ executable?("#@spct_dir/spct")
80
+ end
81
+
82
+ def setup!
83
+ return if setup_ok?
84
+
85
+ clear_dir(@spct_dir, remake: false)
86
+
87
+ Dir.chdir(HOME) do
88
+ sys git, 'clone', '--recursive', 'https://codeberg.org/jneen/spct'
89
+ end
90
+
91
+ Dir.chdir(@spct_dir) do
92
+ sys(make)
93
+ end
94
+ end
95
+ end
96
+
97
+ class CustomSPCPlayer < SPCPlayer
98
+ def initialize(path)
99
+ @path = Ramekin.config.expand(path)
100
+ end
101
+
102
+ def render(fname, outfile, seconds=nil)
103
+ unless File.basename(@path, '.exe') == 'spct'
104
+ $stderr.puts "WAV rendering is only supported for spct."
105
+ exit 1
106
+ end
107
+
108
+ args = [@path, 'render', fname, outfile]
109
+ args.concat(['-s', seconds.to_s]) if seconds
110
+ sys(*args)
111
+ end
112
+
113
+ def play(fname)
114
+ if File.basename(@path, '.exe') == 'spct'
115
+ sys @path, 'play', fname
116
+ else
117
+ sys @path, fname
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end