picotune 0.0.2

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/picotune.rb +542 -0
  3. metadata +57 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: df51a0bf3802fd2ddba38cf31f7bfdc939b6d718a5c7c224880a07c9ac8af936
4
+ data.tar.gz: 6e3ab3ac81fc7b8636dcd99256af65f044eff8ed3372e1574c27df7c644db28e
5
+ SHA512:
6
+ metadata.gz: 1b056316506eaba70a5b34180d9ba1ebbc2c12eb21ccee6de8949646dcbed964f6cb903b6a36d6954c9e17f32d072ab02243e6e133443c7232599e572c41ba99
7
+ data.tar.gz: 4af2a9c00d2950b26385b55687f8ce0ac542f2bc75c0e802bd9e7cfbe332b0313302f9ffc56551ef47f6046d5075a2065347c0257c0a16e86a614010ad9d2769
data/lib/picotune.rb ADDED
@@ -0,0 +1,542 @@
1
+ require 'wavefile'
2
+
3
+ class PicoTune
4
+ SAMPLE_RATE = 11468
5
+ TONE_CONSTANT = 1.059463
6
+ FREQUENCIES = {
7
+ C: 32.70,
8
+ 'C#': 34.65,
9
+ 'Db': 34.65,
10
+ D: 36.71,
11
+ 'D#': 38.89,
12
+ 'Eb': 38.89,
13
+ E: 41.20,
14
+ F: 43.65,
15
+ 'F#': 46.25,
16
+ 'Gb': 46.25,
17
+ G: 49.0,
18
+ 'G#': 51.91,
19
+ 'Ab': 51.91,
20
+ A: 55.00,
21
+ 'A#': 58.27,
22
+ 'Bb': 58.27,
23
+ B: 61.74
24
+ }
25
+
26
+ def initialize filename
27
+ @filename = filename
28
+ @assembler = Assembler.new filename
29
+ @tune = @assembler.assemble
30
+ end
31
+
32
+ def wav
33
+ @tune.wav
34
+ end
35
+ end
36
+
37
+ class PicoTune::Sample
38
+ attr_reader :left, :right
39
+
40
+ def to_a
41
+ [@left, @right]
42
+ end
43
+
44
+ def initialize left = 0.0, right = 0.0
45
+ @left, @right = left.to_f, right.to_f
46
+ end
47
+
48
+ def add sample
49
+ @left += sample.left
50
+ @right += sample.right
51
+
52
+ # "foldback" instead of hard clipping, sounds cool
53
+ if @left > 1.0
54
+ @left = 1.0 - (@left - 1.0) # ex: 1.0 - (1.2 - 1.0) => 1.0 - 0.2 => 0.8
55
+ elsif @left < -1.0
56
+ @left = -1.0 - (@left + 1.0) # ex: -1.0 - (-1.2 + 1.0) => -1.0 - -0.2 => -0.8
57
+ end
58
+
59
+ if @right > 1.0
60
+ @right = 1.0 - (@right - 1.0)
61
+ elsif @right < -1.0
62
+ @right = -1.0 - (@right + 1.0)
63
+ end
64
+
65
+ self # return self to chain ops EX: sample.add(sample).add(sample) etc
66
+ end
67
+
68
+ def modify_left operator, modifier
69
+ @left = @left.send operator, modifier
70
+ end
71
+
72
+ def modify_right operator, modifier
73
+ @right = @right.send operator, modifier
74
+ end
75
+ end
76
+
77
+ class PicoTune::WaveSample
78
+ def initialize tone, samples_per_wave, multiplier = nil
79
+ @tone = tone
80
+ @samples_per_wave = samples_per_wave
81
+ @multiplier = multiplier
82
+ end
83
+
84
+ def sample index
85
+ value = case @tone
86
+ when 'square'
87
+ square index
88
+ when 'sine'
89
+ sine index
90
+ when 'triangle'
91
+ triangle index
92
+ when 'noise'
93
+ noise index
94
+ when 'saw'
95
+ saw index
96
+ else
97
+ sine index
98
+ end
99
+
100
+ PicoTune::Sample.new value, value
101
+ end
102
+
103
+ def sine index
104
+ Math.sin(index / (@samples_per_wave / (Math::PI * 2))) * (@multiplier || 0.5)
105
+ end
106
+
107
+ def saw index
108
+ interval = @samples_per_wave / 2
109
+ half_interval = interval / 2
110
+ percent = ((index + half_interval) % interval) / interval.to_f
111
+ ((0.6 * percent) - 0.3) * (@multiplier || 0.5)
112
+ end
113
+
114
+ def square index
115
+ (index <= @samples_per_wave / 2 ? 1.0 : -1.0) * (@multiplier || 0.25)
116
+ end
117
+
118
+ def noise index
119
+ value = sine index
120
+ rand = Random.rand - 0.5
121
+ value * rand * (@multiplier || 0.5)
122
+ end
123
+
124
+ def triangle index
125
+ half = @samples_per_wave / 2
126
+ quarter = @samples_per_wave / 4
127
+ ramp = 1.0 / quarter
128
+ m = @multiplier || 0.5
129
+
130
+ if index <= half
131
+ if index <= quarter
132
+ index * ramp * m
133
+ else
134
+ (half - index) * ramp * m
135
+ end
136
+ else
137
+ if index <= half + quarter
138
+ -((index - half) * ramp) * m
139
+ else
140
+ -((@samples_per_wave - index) * ramp) * m
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ class PicoTune::Tune
147
+ attr_reader :name, :sequence, :phrases
148
+
149
+ def initialize name, sequence, phrases
150
+ @name = name
151
+ @sequence = sequence
152
+ @phrases = phrases
153
+ end
154
+
155
+ def buffer
156
+ @buffer ||= begin
157
+ tune_buffer_length = @sequence.reduce(0) do |acc, phrase_name|
158
+ acc + @phrases.find { |p| p.name == phrase_name }.buffer_size
159
+ end
160
+
161
+ offset = 0
162
+ samples = Array.new(tune_buffer_length) { PicoTune::Sample.new }
163
+
164
+ @sequence.each do |phrase_name|
165
+ phrase = @phrases.find { |p| p.name == phrase_name }
166
+
167
+ phrase.buffer.each_with_index do |phrase_sample, index|
168
+ tune_sample = samples[offset + index] || PicoTune::Sample.new
169
+ tune_sample.add phrase_sample
170
+ samples[offset + index] = tune_sample
171
+ end
172
+
173
+ offset += phrase.buffer_size
174
+ end
175
+
176
+ samples
177
+ end
178
+ end
179
+
180
+ def wav
181
+ @wav ||= begin
182
+ wav_format = WaveFile::Format.new :stereo, :float, PicoTune::SAMPLE_RATE
183
+ wav_buffer = WaveFile::Buffer.new buffer.map(&:to_a), wav_format
184
+ name = "#{@name}.wav"
185
+
186
+ WaveFile::Writer.new(name, WaveFile::Format.new(:stereo, :pcm_8, PicoTune::SAMPLE_RATE)) do |writer|
187
+ writer.write(wav_buffer)
188
+ end
189
+
190
+ name
191
+ end
192
+ end
193
+ end
194
+
195
+ class PicoTune::Phrase
196
+ attr_reader :name, :tempo, :beats, :subbeats, :melodies
197
+
198
+ def initialize name, tempo, beats, subbeats, melodies
199
+ @name = name
200
+ @tempo = tempo.to_i
201
+ @beats = beats.to_i
202
+ @subbeats = subbeats.to_i
203
+ @melodies = melodies
204
+ end
205
+
206
+ def seconds_per_beat
207
+ 60.0 / @tempo
208
+ end
209
+
210
+ def seconds_per_measure
211
+ seconds_per_beat * @beats
212
+ end
213
+
214
+ def buffer_size
215
+ (seconds_per_measure * PicoTune::SAMPLE_RATE).to_i
216
+ end
217
+
218
+ def buffer
219
+ @buffer ||= begin
220
+ samples = Array.new(buffer_size) { PicoTune::Sample.new }
221
+
222
+ @melodies.each do |melody|
223
+ temp = Array.new(buffer_size) { PicoTune::Sample.new } if melody.instrument.reverb?
224
+ sub_buffer_size = buffer_size / (@beats * @subbeats)
225
+ last_step_number = -1
226
+ carry_over = 0
227
+
228
+ melody.pattern.steps.each_with_index do |note, step_number|
229
+ unless note == '.'
230
+ buffer_pointer = step_number * sub_buffer_size
231
+ local_index = 0
232
+ wave_index = 0
233
+ length_offset = (1 - melody.instrument.length_value) * sub_buffer_size
234
+
235
+ if step_number == last_step_number + 1
236
+ local_index = carry_over
237
+ end
238
+
239
+ carry_over = 0
240
+
241
+ while local_index + length_offset < sub_buffer_size || !wave_index.zero?
242
+ current_sample = (temp ? temp : samples)[buffer_pointer + local_index] || PicoTune::Sample.new
243
+
244
+ new_sample = melody.instrument.wave wave_index, note
245
+
246
+ current_sample.add new_sample
247
+
248
+ (temp ? temp : samples)[buffer_pointer + local_index] = current_sample
249
+
250
+ wave_index += 1
251
+ local_index += 1
252
+ last_step_number = step_number
253
+ carry_over += 1 if local_index + length_offset >= sub_buffer_size
254
+ wave_index = 0 if wave_index >= melody.instrument.samples_per_wave(note)
255
+ end
256
+
257
+ if melody.instrument.reverb?
258
+ i = 0
259
+ while i < temp.size
260
+ if i + melody.instrument.reverb_offset < temp.size
261
+ verb_sample = temp[i + melody.instrument.reverb_offset]
262
+ verb_sample.modify_left :+, temp[i].left * melody.instrument.decay
263
+ verb_sample.modify_right :+, temp[i].right * melody.instrument.decay
264
+
265
+ temp[i + melody.instrument.reverb_offset] = verb_sample
266
+ end
267
+
268
+ samples[i] = samples[i].add temp[i]
269
+
270
+ i += 1
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+
277
+ samples
278
+ end
279
+ end
280
+ end
281
+
282
+ class PicoTune::Instrument
283
+ attr_reader :name, :tone, :length, :volume, :pan, :reverb
284
+
285
+ def initialize name, tone = 0, length = 'full', volume = 'full', pan = 'center', reverb = 'none'
286
+ @name = name
287
+ @tone = tone
288
+ @length = length
289
+ @volume = volume
290
+ @pan = pan
291
+ @reverb = reverb
292
+ end
293
+
294
+ def length_value
295
+ case @length
296
+ when 'none'
297
+ 0.0
298
+ when 'quarter'
299
+ 0.25
300
+ when 'half'
301
+ 0.5
302
+ when 'threequarters'
303
+ 0.75
304
+ when 'full'
305
+ 1.0
306
+ end
307
+ end
308
+
309
+ def volume_value
310
+ case @volume
311
+ when 'none'
312
+ 0.0
313
+ when 'quarter'
314
+ 0.25
315
+ when 'half'
316
+ 0.5
317
+ when 'threequarters'
318
+ 0.75
319
+ when 'full'
320
+ 1.0
321
+ end
322
+ end
323
+
324
+ def pan_value
325
+ case @pan
326
+ when 'left'
327
+ 0
328
+ when 'centerleft'
329
+ 1
330
+ when 'center'
331
+ 2
332
+ when 'centerright'
333
+ 3
334
+ when 'right'
335
+ 4
336
+ end
337
+ end
338
+
339
+ def delay
340
+ @reverb == 'none' ? 0.0 : 0.1
341
+ end
342
+
343
+ def decay
344
+ case @reverb
345
+ when 'none'
346
+ 0.0
347
+ when 'some'
348
+ 0.25
349
+ when 'more'
350
+ 0.5
351
+ when 'lots'
352
+ 0.75
353
+ else
354
+ 0.0
355
+ end
356
+ end
357
+
358
+ def reverb_offset
359
+ (PicoTune::SAMPLE_RATE * delay).floor
360
+ end
361
+
362
+ def reverb?
363
+ %w(some more lots).include? @reverb
364
+ end
365
+
366
+ def wave wave_index, note
367
+ frequency = frequency_for_note note
368
+ samples_per_wave = (PicoTune::SAMPLE_RATE / frequency).ceil
369
+ sample = PicoTune::WaveSample.new(@tone, samples_per_wave).sample wave_index
370
+ sample.modify_left :*, volume_value * (1 - pan_value / 4.0)
371
+ sample.modify_right :*, volume_value * (pan_value / 4.0)
372
+ sample
373
+ end
374
+
375
+ def samples_per_wave note
376
+ frequency = frequency_for_note note
377
+ (PicoTune::SAMPLE_RATE / frequency).ceil end
378
+
379
+ def frequency_for_note note
380
+ parts = note.split ''
381
+ octave = parts.pop.to_i
382
+ name = parts.join.to_sym
383
+ freq = PicoTune::FREQUENCIES[name]
384
+
385
+ raise "Bad note: #{name} from #{note}. Valid note names are <C, C# or Db, D, D# or Eb, E, F, F# or Gb, G, G# or Ab, A, A# or Bb, B>" unless freq
386
+ raise "Bad octave: #{octave} from #{note}. Valid octave number is 1..8" unless (1..8).include?(octave)
387
+
388
+ octave_shift = PicoTune::TONE_CONSTANT ** 12
389
+ (octave - 1).times { freq = freq * octave_shift }
390
+
391
+ freq
392
+ end
393
+ end
394
+
395
+ class PicoTune::Melody
396
+ attr_reader :instrument, :pattern
397
+
398
+ def initialize instrument, pattern
399
+ raise 'nil instrument' if instrument.nil?
400
+ raise 'nil pattern' if pattern.nil?
401
+
402
+ @instrument = instrument
403
+ @pattern = pattern
404
+ end
405
+ end
406
+
407
+ class PicoTune::Pattern
408
+ attr_reader :name, :steps
409
+
410
+ def initialize name, steps
411
+ @name = name
412
+ @steps = steps
413
+ end
414
+ end
415
+
416
+ class PicoTune::Assembler
417
+ attr_reader :phrases, :instruments, :patterns
418
+
419
+ def initialize file
420
+ @file = file
421
+ @parser = PicoTune::Parser.new file
422
+ end
423
+
424
+ def assemble
425
+ patterns = []
426
+ phrases = []
427
+
428
+ list = @parser.parse
429
+
430
+ instruments = list.select { |item| item['type'] == 'instrument' }.map do |item|
431
+ PicoTune::Instrument.new(
432
+ item['name'],
433
+ item['tone'],
434
+ item['length'],
435
+ item['volume'],
436
+ item['pan'],
437
+ item['reverb']
438
+ )
439
+ end
440
+
441
+ patterns = list.select { |item| item['type'] == 'pattern' }.map do |item|
442
+ PicoTune::Pattern.new item['name'], item['list']
443
+ end
444
+
445
+ phrases = list.select { |item| item['type'] == 'phrase' }.map do |item|
446
+ melodies = item['melodies'].map do |m|
447
+ instrument = instruments.find { |i| i.name == m[0] }
448
+ pattern = patterns.find { |p| p.name == m[1] }
449
+
450
+ PicoTune::Melody.new instrument, pattern
451
+ end
452
+
453
+ PicoTune::Phrase.new(
454
+ item['name'],
455
+ item['tempo'],
456
+ item['beats'],
457
+ item['subbeats'],
458
+ melodies
459
+ )
460
+ end
461
+
462
+ sequence = list.find { |item| item['type'] == 'sequence' }
463
+ sequence['list'].each do |phrase_name|
464
+ raise "undefined phrase \"#{phrase_name}\" in sequence" unless phrases.find { |p| p.name == phrase_name }
465
+ end
466
+
467
+
468
+ tune = list.find { |item| item['type'] == 'tune' }
469
+ raise 'undefined tune name' unless tune['name']
470
+
471
+ PicoTune::Tune.new tune['name'], sequence['list'], phrases
472
+ end
473
+ end
474
+
475
+ class PicoTune::Parser
476
+ def initialize file
477
+ @lines = File.open(file).readlines.map(&:strip)
478
+ @keywords = ['tune ', 'sequence', 'instrument ', 'phrase ', 'pattern ']
479
+ end
480
+
481
+ def parse
482
+ i = 0
483
+ bag = []
484
+ collecting_melodies = false
485
+
486
+ while i < @lines.length
487
+ line = @lines[i]
488
+
489
+ if line.start_with? *@keywords
490
+ collecting_melodies = false
491
+ parts = line.split ' '
492
+ item = {}
493
+
494
+ item['type'] = parts[0]
495
+
496
+ if parts[0] == 'sequence'
497
+ item['list'] = parts[1..-1]
498
+ elsif parts[0] == 'pattern'
499
+ item['name'] = parts[1]
500
+ item['list'] = pattern_steps(parts[2])
501
+ else
502
+ item['name'] = parts[1]
503
+ end
504
+
505
+ bag << item
506
+ elsif line.length > 0
507
+ parts = line.split ' '
508
+
509
+ if bag.last['type'] == 'phrase' && parts[0] == 'melodies'
510
+ collecting_melodies = true
511
+ bag.last['melodies'] = []
512
+ elsif collecting_melodies
513
+ bag.last['melodies'].push parts
514
+ else
515
+ bag.last[parts[0]] = parts[1]
516
+ end
517
+ end
518
+
519
+ i += 1
520
+ end
521
+
522
+ bag
523
+ end
524
+
525
+ def pattern_steps pattern
526
+ p = pattern.split(/([a-zA-Z][#b]?\d)/, -1)
527
+ .map { |b| b.split(/(\.|-)/, -1) }
528
+ .flatten
529
+ .delete_if { |b| b.length.zero? }
530
+
531
+ i = 0
532
+ while i < p.length
533
+ if p[i] == '-'
534
+ p[i] = p[i - 1]
535
+ end
536
+
537
+ i += 1
538
+ end
539
+
540
+ p
541
+ end
542
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: picotune
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Zachary Schroeder
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-10-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: wavefile
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.1.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.1.1
27
+ description: Use a text file with a simple DSL to generate a musical (maybe) wav file.
28
+ email: schroza@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - lib/picotune.rb
34
+ homepage: https://rubygems.org/gems/picotune
35
+ licenses:
36
+ - MIT
37
+ metadata: {}
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.2.3
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: Text file -> wav file. Make tiny tunes!
57
+ test_files: []