sfzer 0.4

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/History.txt ADDED
@@ -0,0 +1,39 @@
1
+ === 0.4.0 / 2009-02-08
2
+
3
+ * 4 Improvements:
4
+
5
+ * Smarter Multisample.get_xfade method: xfade in/outs are only calculated when needed (i.e., for gaps between Samples that are over a semitone).
6
+ * Introduced a better system for unique filenames when using the --top-dir option.
7
+ * Text entered via --message now wraps every 80 lines.
8
+ * Upgraded license to GPLv3.
9
+
10
+ * 3 Fixes:
11
+
12
+ * Corrected @padding bug: @padding now only applies to the edge samples.
13
+ * Fixed file separator bug that created incompatible SFZ files when run from unix shells (Test added).
14
+ * Added non-intrusive path declarations to the sfzer executable and the project Rakefile for maximum compatibility.
15
+
16
+
17
+ === 0.3.0 / 2008-03-09
18
+
19
+ * 2 Features:
20
+
21
+ * Added @padding to extend the edges of keycentered Multisample regions.
22
+ * Supports relative paths to samples (so all SFZ files can be generated in the same, top-level directory.
23
+
24
+
25
+ === 0.2.0 / 2008-02-18
26
+
27
+ * 3 Features:
28
+
29
+ * xfade support.
30
+ * Introduced cli option handling.
31
+ * Introduced script exit codes.
32
+
33
+ * 1 Fix:
34
+ * Corrected note/octave mapping: c1 up through b1 == octave 1 (Test added).
35
+
36
+
37
+ === 0.1.0 / 2008-02-16
38
+
39
+ * First working version.
data/Manifest.txt ADDED
@@ -0,0 +1,14 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ COPYING
5
+ Rakefile
6
+ bin/sfzer
7
+ lib/sfzer.rb
8
+ lib/DirectoryProcessor.rb
9
+ lib/Sample.rb
10
+ lib/NamedSample.rb
11
+ lib/Multisample.rb
12
+ test/test_sfzer.rb
13
+ test/NamedSampleTest.rb
14
+ test/MultisampleTest.rb
data/README.txt ADDED
@@ -0,0 +1,67 @@
1
+ = SFZer
2
+
3
+ * http://sfzer.rubyforge.org
4
+ * http://christessmer.com
5
+
6
+ == DESCRIPTION:
7
+
8
+ SFZer recursively scans through directories of Multisamples (.wav, .aiff, .ogg,
9
+ etc) and automagically generates SFZ soundfonts from what it finds.
10
+
11
+
12
+ === Related Projects:
13
+ * The *SFZ* format: http://www.cakewalk.com/DevXchange/sfz.asp
14
+ * The *makesfz.pl* perl scripts , by Peter L. Jones: http://www.drealm.info/sfz/
15
+
16
+
17
+ == FEATURES/PROBLEMS:
18
+
19
+ * Quickly converts entire directory trees of named samples into SFZ instruments.
20
+ * Creates quick keyboard mapping from sample file names.
21
+ * Automatically computes xfades between adjacent samples over a semitone apart.
22
+
23
+ === Future Plans:
24
+ * Future Music Sample name format
25
+ * Numeric Notes
26
+ * loop_mode=one-shot option for percussion
27
+
28
+ === Ideas without much substance yet:
29
+ * Option to force SFZv1 and SFZv2 output (nothing requires this yet)
30
+ * Default effect/ CC controller options
31
+ * (possibly) 2-n pass (as opposed to 1-pass) processing?
32
+
33
+
34
+
35
+ == SYNOPSIS:
36
+
37
+ % sfzer [options] directorie(s)
38
+
39
+
40
+ == REQUIREMENTS:
41
+
42
+ * Ruby
43
+
44
+
45
+ == INSTALL:
46
+
47
+ * sudo gem install sfzer
48
+
49
+
50
+ == LICENSE:
51
+
52
+ (The GPL License, version 3)
53
+
54
+ Copyright (c) 2008 Chris Tessmer
55
+
56
+ This program is free software: you can redistribute it and/or modify
57
+ it under the terms of the GNU General Public License as published by
58
+ the Free Software Foundation, either version 3 of the License, or
59
+ (at your option) any later version.
60
+
61
+ This program is distributed in the hope that it will be useful,
62
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
63
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
64
+ GNU General Public License for more details.
65
+
66
+ You should have received a copy of the GNU General Public License
67
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # -*- ruby -*-
2
+
3
+ # Add lib/ and test/ to the load paths
4
+ $: << "./lib"
5
+ $: << "./test"
6
+
7
+ require 'rubygems'
8
+ require 'hoe'
9
+ require './lib/sfzer.rb'
10
+
11
+
12
+ Hoe.new('sfzer', SFZer::VERSION) do |p|
13
+ # p.rubyforge_name = 'sfzerx' # if different than lowercase project name
14
+ p.developer('Chris Tessmer', 'http://christessmer.com')
15
+ p.remote_rdoc_dir = '' # Release to root
16
+ end
17
+
18
+ # vim: syntax=Ruby
data/bin/sfzer ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/ruby
2
+ #
3
+ # SFZer, by Chris Tessmer ( http://christessmer.com )
4
+ #
5
+
6
+ #--
7
+ # Ensure that the classpath loads regardless of environment
8
+ $: << File.dirname(__FILE__) + '/../lib/'
9
+ #++
10
+
11
+ require 'sfzer.rb'
12
+ s = SFZer.new
13
+ s.do( ARGV )
@@ -0,0 +1,174 @@
1
+ # This file is part of SFZer.
2
+ #
3
+ # SFZer is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # SFZer is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with SFZer. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+
17
+ # Processes one or more directories
18
+ #
19
+ # * Scan for Multisamples
20
+ # *
21
+ #
22
+ # TODO: Refactor this into an application class
23
+ class DirectoryProcessor
24
+
25
+ attr_accessor :dirs, :message, :mapping, :generate_in_top_dir
26
+
27
+ # Instantiate the DirectoryProcessor
28
+ def initialize
29
+ # root directories to look for samples under
30
+ @dirs = []
31
+
32
+ # optional message to place in the header of each SFZ file
33
+ @message = false
34
+
35
+ # keymapping setting, set to a Multisample contant.
36
+ # The currently supported values are:
37
+ # * Multisample::XFADE (default )
38
+ # * Multisample::STRICTKEYS
39
+ @mapping = Multisample::XFADE
40
+
41
+ # If true (which is the default), all SFZ files are generated in the
42
+ # directory the script was called from.
43
+ @generate_in_top_dir = true
44
+ end
45
+
46
+
47
+ # starting from directory,
48
+ def process_multisample_dir( path, top_path )
49
+ named_hash = {}
50
+ numbered_hash = Hash.new
51
+
52
+ puts
53
+
54
+ Dir.chdir( path )
55
+ Dir.glob( "*#{Sample.suffix_glob}" ).each{ |file|
56
+
57
+ # Start tracking potential instrument names
58
+ if NamedSample.sample?( file )
59
+ # Sanitize hashkeys
60
+ file_basename = File.basename( file )
61
+
62
+ # FIXME: Potential bug: this assumes the first portion of the string is the most
63
+ # significant indicator
64
+ (file_hashkey, ) = file_basename.gsub( /\.#{Sample.suffix_regexp}$/, '' ).sub( NamedSample.regexp, '?' ).split('?')
65
+
66
+
67
+ # if there is no label for the multisample, name it after the directory it's in.
68
+ if file_hashkey.nil?
69
+ file_hashkey = File.basename( path )
70
+ else
71
+ # otherwise, strip it of common paired symbols
72
+ file_hashkey.sub!( /_\d\d(_)$/, '' )
73
+ file_hashkey.sub!( /[(\[{_]$/, '' )
74
+ file_hashkey.sub!( /\s*$/, '' )
75
+ end
76
+
77
+ #add Sample to a hashed Multisample
78
+ multi = get_multisample( file_hashkey, path, named_hash )
79
+ multi.add( NamedSample.new( file_basename ) )
80
+ named_hash[ file_hashkey ] = multi
81
+ end
82
+ }
83
+ puts
84
+
85
+ # for each hash entry, make an SFZ
86
+ for key in named_hash.keys.sort
87
+ puts "#{key}: "
88
+ puts named_hash[ key ].to_sfz
89
+
90
+ # generate the SFZ file from the Multisample
91
+ extra_name = ""
92
+ if @generate_in_top_dir
93
+ Dir.chdir( top_path )
94
+ relative_path = path.gsub( /^\\/, '' ).gsub( /^\//, '' )
95
+ named_hash[ key ].default_path = true
96
+
97
+ # create a name based on subfolders to ensure uniqueness
98
+ extra_name = relative_path.gsub( /\\/, "_" ).gsub( /\//, "_" ) + "_"
99
+ extra_name.gsub!(/^(\.|_)*/, '')
100
+ end
101
+
102
+
103
+ f = File.new( "#{extra_name}#{key}.sfz", "w" )
104
+ f.puts named_hash[ key ].to_sfz
105
+ f.close
106
+
107
+
108
+ end
109
+ end
110
+
111
+
112
+ # Scan each directory for multisamples
113
+ def scan_dirs
114
+ # tidy this junk up
115
+ multidirs = []
116
+
117
+ for dir in @dirs
118
+ if File.exists?( dir ) and File.stat( dir ).directory? \
119
+ and Multisample.dir_contains_multisamples?( dir )
120
+
121
+ puts "\"#{ dir }\" has multisamples!"
122
+ multidirs.push( dir )
123
+
124
+ elsif File.exists?( dir ) and File.stat( dir ).directory?
125
+
126
+ Dir.foreach( dir ){ |file|
127
+
128
+ current_dir = dir + File::SEPARATOR + file
129
+ if File.stat( current_dir ).directory? and file !~ /^\./
130
+ puts "pushing \"#{current_dir}\""
131
+ @dirs.push( current_dir )
132
+ end
133
+ }
134
+
135
+ puts "pushed \"#{ dir }\""
136
+ else
137
+ puts "\"#{ dir }\" is unusable."
138
+ end
139
+ end
140
+
141
+ puts
142
+ pwd = Dir.getwd
143
+ if multidirs.size > 0
144
+ puts "converting..."
145
+ for dir in multidirs
146
+ Dir.chdir( pwd )
147
+ process_multisample_dir( dir, pwd )
148
+ end
149
+ else
150
+ STDERR.print "ERROR: No multisample directories were found in the given "
151
+ STDERR.puts "arguments."
152
+ STDERR.puts "Quitting."
153
+ exit SFZer::EXITSTATUS_NO_MULTISAMPLES_FOUND
154
+ end
155
+ end
156
+
157
+
158
+ protected
159
+
160
+
161
+ # Returns the Multisample for the given hashkey.
162
+ # If no Multisample exists under that hashkey, a new Multisample is created.
163
+ def get_multisample( file_hashkey, path, named_hash )
164
+ multi = false
165
+ if named_hash.has_key?( file_hashkey )
166
+ multi = named_hash[ file_hashkey ]
167
+ else
168
+ multi = Multisample.new( path, file_hashkey )
169
+ multi.mapping = @mapping
170
+ multi.message = @message if( @message )
171
+ end
172
+ multi
173
+ end
174
+ end
@@ -0,0 +1,320 @@
1
+ # This file is part of SFZer.
2
+ #
3
+ # SFZer is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # SFZer is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with SFZer. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+
17
+
18
+ # A Multisample is a collection of related Samples that can form an intrument.
19
+ class Multisample
20
+ # Region-mapping constants
21
+
22
+ # auto-xfade (usually best)
23
+ XFADE = 0
24
+
25
+ # 1:1 key map (usually worst)
26
+ STRICTKEYS = 1
27
+
28
+ # Keycurve constants
29
+
30
+ # Gain keycurve
31
+ KEYCURVE_GAIN = 3
32
+
33
+ # Power keycurve
34
+ KEYCURVE_POWER = 4
35
+
36
+ # Maximum number of columns in SFZ comments before a line wrap
37
+ LINE_LIMIT = 80
38
+
39
+ attr_accessor :message, :mapping, :default_path
40
+
41
+
42
+
43
+ # instantiate the Multisample
44
+ def initialize( path, name=false )
45
+ #TODO: Raise Exception if path not valid
46
+ @path=path
47
+ @samples=Array.new
48
+ @name = "Unnamed Multisample"
49
+ if name
50
+ @name=name.chomp.gsub(/^\s*/, '').gsub(/\s*$/, '')
51
+ end
52
+ @mapping = XFADE
53
+ @message = false # SFZ header message
54
+ @padding = 12 # padding in semitones
55
+
56
+ # include a default path?
57
+ @default_path = false
58
+ end
59
+
60
+
61
+
62
+ # add a Sample to the Multisample
63
+ def add( sample )
64
+ @samples.push( sample )
65
+ @samples.sort!
66
+ end
67
+
68
+
69
+
70
+ # Sreturn a String representation of the Multisample
71
+ def to_s
72
+ "Multisample \"#{@name}\" [#{@samples.size}]: " + @samples.sort.to_s
73
+ end
74
+
75
+
76
+
77
+ # Returns a string of SFZ elements
78
+ def to_sfz
79
+ result = sfz_banner
80
+
81
+ if @default_path
82
+ sanitized_path = sanitize_file_path( @path )
83
+ result = result + "\n<control>\ndefault_path=#{sanitized_path}\\\n\n"
84
+ end
85
+
86
+ maxfilenamesize = max_sample_filename_size
87
+
88
+ #result = result + "// Keyboard/Sample mappings:\n"
89
+ @samples.each_index{ |i|
90
+ sample=@samples[i]
91
+ prev_sample = get_previous_sample( i )
92
+ next_sample = get_next_sample( i )
93
+ result = result + "<region> sample=" + sanitize_file_path( sample.sample.ljust( maxfilenamesize + 1 ) )
94
+
95
+ if @mapping == XFADE
96
+ result = result + get_xfade( sample, prev_sample, next_sample )
97
+ elsif @mapping == STRICTKEYS
98
+ result = result + "key=#{sample.value} "
99
+ end
100
+
101
+ # *****************************************
102
+ # TODO: PLACE FUTURE PER-SAMPLE LOGIC HERE
103
+ # *****************************************
104
+
105
+ result = result + "\n"
106
+ }
107
+
108
+ result
109
+ end
110
+
111
+
112
+
113
+ # Sanitizes file path separators.
114
+ #
115
+ # Whilst every path separator within Ruby must be "/" (Unix), evey path
116
+ # separator within SFZ must be "\" (DOS).
117
+ def sanitize_file_path( f )
118
+ f.gsub( File::SEPARATOR, "\\" )
119
+ end
120
+
121
+
122
+
123
+ # Returns SFZ opcodes to specify pitch_keycenter and xfade parameters for a
124
+ # region.
125
+ def get_xfade( sample, prev_sample, next_sample, keycurve=false )
126
+ result = "pitch_keycenter=#{sample.value} "
127
+ xfin_lo = false
128
+ xfin_hi = false
129
+ xfout_lo = false
130
+ xfout_hi = false
131
+ lokey = false
132
+ hikey = false
133
+
134
+ # calculate xfade ins from previous sample
135
+ if prev_sample and ( prev_sample.value < (sample.value-1) )
136
+ mid = ( sample.value + prev_sample.value ) / 2
137
+ diff = sample.value - mid
138
+ xfin_lo = mid - (diff/2)
139
+ xfin_hi = mid + (diff/2)
140
+ lokey = xfin_lo
141
+ elsif prev_sample and ( sample.value - prev_sample.value == 1 )
142
+ lokey = sample.value
143
+ else
144
+ lokey = sample.value - @padding
145
+ end
146
+
147
+ #calculate xfade outs into next sample
148
+ if next_sample and ( next_sample.value > (sample.value+1) )
149
+ mid = ( sample.value + next_sample.value ) / 2
150
+ diff = sample.value - mid
151
+ xfout_hi = mid - (diff/2)
152
+ xfout_lo = mid + (diff/2)
153
+ hikey = xfout_hi
154
+ elsif next_sample and ( next_sample.value - sample.value == 1 )
155
+ hikey = sample.value
156
+ else
157
+ hikey = sample.value + @padding
158
+ end
159
+
160
+ # depending on the distance between lokey and hikey, apply xfades
161
+
162
+
163
+ if lokey == hikey and lokey == sample.value
164
+ result = "key=#{sample.value}"
165
+ else
166
+
167
+ if lokey
168
+ result = result + "lokey=#{lokey} "
169
+ if xfin_lo and xfin_hi
170
+ result = result + "xfin_lokey=#{xfin_lo} xfin_hikey=#{xfin_hi} "
171
+ end
172
+ end
173
+
174
+ if hikey
175
+ if xfout_lo and xfout_hi
176
+ result = result + "xfout_lokey=#{xfout_lo} xfout_hikey=#{xfout_hi} "
177
+ end
178
+ result = result + "hikey=#{hikey} "
179
+ end
180
+ end
181
+
182
+ # add xf_keycurve, if applicable
183
+ if keycurve == KEYCURVE_GAIN
184
+ result = result + "xf_keycurve=gain"
185
+ elsif keycurve == KEYCURVE_POWER
186
+ result = result + "xf_keycurve=power"
187
+ end
188
+
189
+ result
190
+ end
191
+
192
+
193
+
194
+ # returns true if a filename can be understood as a multisample element
195
+ def Multisample.multisample_filename?( name )
196
+ types = Sample.suffix_regexp
197
+
198
+ # test for A(#)1.wav type
199
+ #return true if name =~ /^.*([ABGCDEFG](#)?\d).*\.#{types}$/i
200
+ return true if NamedSample.sample?( name )
201
+
202
+ # test for Sample (0)00.wav type
203
+ if name =~ /^.*((0|1)?\d\d)\.#{types}$/i
204
+ return true if (0..127).include?( $1.to_i )
205
+ end
206
+ false
207
+ end
208
+
209
+
210
+
211
+ # returns true if the directory contains files that might be multisamples
212
+ def Multisample.dir_contains_multisamples?( dir )
213
+ result = false
214
+ Dir.foreach( dir ) do |file|
215
+ if Multisample.multisample_filename?( file )
216
+ result = true
217
+ break
218
+ end
219
+ end
220
+ result
221
+ end
222
+
223
+
224
+
225
+ protected
226
+
227
+ # returns the maximum filename length of all Samples within the Multisample
228
+ def max_sample_filename_size
229
+ # Find the maximum length of sample filenames
230
+ # NOTE: this seems inefficient
231
+ maxfilenamesize = 0
232
+ @samples.each{ |sample|
233
+ if sample.sample.size > maxfilenamesize
234
+ maxfilenamesize = sample.sample.size
235
+ end
236
+ }
237
+ maxfilenamesize
238
+ end
239
+
240
+
241
+
242
+ # Returns the previous Sample in the Multisample (relative to a given Sample)
243
+ def get_previous_sample( i )
244
+ prev_sample=false
245
+ if i>0 and (not @samples[ i-1 ].nil?)
246
+ prev_sample = @samples[ i-1 ]
247
+ end
248
+ prev_sample
249
+ end
250
+
251
+
252
+
253
+ # Returns the next Sample in the Multisample (relative to a given Sample)
254
+ def get_next_sample( i )
255
+ next_sample=false
256
+ if not @samples[ i+1 ].nil?
257
+ next_sample = @samples[ i+1 ]
258
+ end
259
+ next_sample
260
+ end
261
+
262
+
263
+ public
264
+
265
+ # Returns the Multisample's SFZ header as a string.
266
+ def sfz_banner
267
+ separator = "//----------------------------------------------------------------------------"
268
+ result = ""
269
+
270
+
271
+ if @name
272
+ result = result + "//\n"
273
+ result = result + "// "+ "*** SFZ Intrument: #{@name} ***".center(76) + "\n"
274
+ result = result + "//\n"
275
+ end
276
+
277
+ # add optional message to SFZ header
278
+ if @message and @message.chomp.size > 0
279
+ result = result + separator + "\n"
280
+ result = result + sfz_banner_message( @message )
281
+
282
+ end
283
+
284
+ # add generator message to SFZ header
285
+ result = result + separator + "\n"
286
+ result = result + "// Generated on #{Time.now.strftime("%Y/%m/%d %I:%M%p") } "
287
+ result = result + "by #{SFZer::NAME} v#{SFZer::VERSION} (#{SFZer::URL})\n"
288
+ result = result + "#{separator}\n"
289
+ result
290
+ end
291
+
292
+ # Returns a formatted message formatted to the SFZ
293
+ def sfz_banner_message( fulltext )
294
+ limit = 76
295
+ result = ""
296
+ result = result + "//\n"
297
+
298
+ fulltext.each_line{ |text|
299
+ while text.length > limit
300
+ subtext = text.slice( 0 .. ( limit - 1 ) )
301
+ text = text.slice( limit .. text.length )
302
+
303
+ # don't cut off words; break at spaces
304
+ i = subtext.rindex( ' ' )
305
+ if i > ( limit / 2 )
306
+ nub = subtext.slice( i+1, subtext.length )
307
+ subtext = subtext.slice( 0..i )
308
+ text = nub + text
309
+ end
310
+
311
+ result = result + "// " + subtext.center( limit ) + "\n"
312
+ end
313
+
314
+ result = result + "// " + text.center( limit ) + "\n"
315
+ }
316
+ result = result + "//\n"
317
+
318
+ result
319
+ end
320
+ end