dopairb 0.1.0 → 0.2.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.
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "rbconfig"
6
+
7
+ module Dopairb
8
+ # Sound effects synthesized at runtime (no bundled audio) and played by
9
+ # whatever the OS offers: paplay (PulseAudio, incl. WSLg), aplay, afplay,
10
+ # or PowerShell on WSL without WSLg. Falls back to the terminal bell.
11
+ module Sound
12
+ RATE = 22_050
13
+
14
+ # Waveform building blocks. Each returns an array of floats in -1..1.
15
+ module Synth
16
+ module_function
17
+
18
+ def silence(dur) = Array.new((dur * RATE).round, 0.0)
19
+
20
+ def tone(freq, dur, wave: :square, vol: 0.3, slide_to: nil, attack: 0.004, decay: nil, vibrato: 0.0)
21
+ n = (dur * RATE).round
22
+ phase = 0.0
23
+ Array.new(n) do |i|
24
+ t = i.fdiv(RATE)
25
+ f = slide_to ? freq * (slide_to.fdiv(freq))**(i.fdiv(n)) : freq
26
+ f *= 1 + vibrato * Math.sin(t * 2 * Math::PI * 6) if vibrato > 0
27
+ phase += f / RATE
28
+ p = phase % 1.0
29
+ s = case wave
30
+ when :square then p < 0.5 ? 1.0 : -1.0
31
+ when :saw then 2 * p - 1
32
+ when :triangle then 1 - 4 * (p - 0.5).abs
33
+ else Math.sin(phase * 2 * Math::PI)
34
+ end
35
+ env = t < attack ? t / attack : 1.0
36
+ env *= decay ? Math.exp(-t / decay) : (1 - i.fdiv(n))
37
+ s * env * vol
38
+ end
39
+ end
40
+
41
+ # Low-passed white noise; smooth 0..1 (higher = darker).
42
+ def noise(dur, vol: 0.4, decay: 0.1, smooth: 0.0, rng: Random.new(7))
43
+ prev = 0.0
44
+ Array.new((dur * RATE).round) do |i|
45
+ prev = prev * smooth + (rng.rand * 2 - 1) * (1 - smooth)
46
+ prev * vol * Math.exp(-i.fdiv(RATE) / decay)
47
+ end
48
+ end
49
+
50
+ def seq(*parts) = parts.flatten
51
+
52
+ def mix(*tracks)
53
+ len = tracks.map(&:size).max || 0
54
+ Array.new(len) { |i| tracks.sum { |tr| tr[i] || 0.0 } }
55
+ end
56
+
57
+ def at(offset, track) = silence(offset) + track
58
+
59
+ def arp(freqs, step, **kw)
60
+ freqs.map { |f| tone(f, step, **kw) }.flatten
61
+ end
62
+
63
+ # Mix track into buf starting at t seconds (grows buf as needed).
64
+ def add(buf, t, track, gain = 1.0)
65
+ o = (t * RATE).round
66
+ need = o + track.size
67
+ buf.fill(0.0, buf.size...need) if need > buf.size
68
+ track.each_with_index { |v, i| buf[o + i] += v * gain }
69
+ buf
70
+ end
71
+
72
+ # Noise that swells instead of decaying.
73
+ def riser(dur, vol: 0.3, smooth: 0.3, rng: Random.new(11))
74
+ n = (dur * RATE).round
75
+ prev = 0.0
76
+ Array.new(n) do |i|
77
+ prev = prev * smooth + (rng.rand * 2 - 1) * (1 - smooth)
78
+ prev * vol * (i.fdiv(n)**2)
79
+ end
80
+ end
81
+
82
+ def boom(dur = 0.6, vol: 0.6, low: 80)
83
+ mix(noise(dur, vol: vol * 0.8, decay: dur / 3.5, smooth: 0.6), tone(low, dur, wave: :sine, vol: vol, slide_to: low / 2.0, decay: dur / 3))
84
+ end
85
+ end
86
+
87
+ # Session finale, laid out on Scenes::Finale::Timeline; k stretches time.
88
+ def self.finale(k = 1.0)
89
+ tl = Scenes::Finale::Timeline
90
+ s = Synth
91
+ buf = []
92
+ rng = Random.new(5)
93
+ s.add(buf, 0, s.tone(120, tl::DROP * k, wave: :saw, vol: 0.14, slide_to: 700))
94
+ s.add(buf, 0, s.riser(tl::DROP * k, vol: 0.35))
95
+ s.add(buf, tl::DROP * k, s.boom(0.6, vol: 0.7))
96
+ s.add(buf, tl::DROP * k, s.mix(*[523, 659, 784].map { |f| s.tone(f, 0.35, vol: 0.06, decay: 0.15) }))
97
+ tl::ROWS.times do |i|
98
+ st = (tl.row_start(i) + tl::SLIDE) * k
99
+ n = (tl::COUNT * k / 0.03).floor
100
+ n.times { |j| s.add(buf, st + j * 0.03, s.tone(900 + i * 70 + j * 15, 0.012, vol: 0.08, decay: 0.005)) }
101
+ f = Scenes::Finale::NOTES[i]
102
+ land = tl.row_land(i) * k
103
+ s.add(buf, land, s.tone(f, 0.28, wave: :triangle, vol: 0.22, decay: 0.12))
104
+ s.add(buf, land, s.tone(f * 2, 0.2, wave: :sine, vol: 0.06, decay: 0.08))
105
+ s.add(buf, land, s.noise(0.03, vol: 0.12, decay: 0.01, rng: rng))
106
+ end
107
+ a = tl.score_start * k
108
+ b = tl.score_land * k
109
+ t = a
110
+ i = 0
111
+ while t < b - 0.01
112
+ p = (t - a) / (b - a)
113
+ s.add(buf, t, s.noise(0.03, vol: 0.12 + 0.4 * p, decay: 0.012, smooth: 0.3, rng: rng))
114
+ t += 0.045 - 0.02 * p
115
+ i += 1
116
+ end
117
+ s.add(buf, a, s.tone(180, b - a, wave: :saw, vol: 0.08, slide_to: 900))
118
+ s.add(buf, b, s.boom(0.9, vol: 0.8, low: 70))
119
+ s.add(buf, b, s.noise(1.1, vol: 0.22, decay: 0.45, smooth: 0.0, rng: rng))
120
+ s.add(buf, b, s.mix(*[523, 659, 784, 1047].map { |f| s.tone(f, 0.6, wave: :square, vol: 0.05, decay: 0.25) }))
121
+ r = tl.rank * k
122
+ s.add(buf, r, s.mix(s.noise(0.05, vol: 0.5, decay: 0.015, rng: rng), s.tone(110, 0.25, wave: :sine, vol: 0.7, slide_to: 55, decay: 0.08)))
123
+ fan = s.arp([392, 523, 659, 784], 0.09, vol: 0.13, decay: 0.07)
124
+ s.add(buf, r + 0.06, fan)
125
+ hold = r + 0.06 + fan.size.fdiv(RATE)
126
+ [523, 659, 784, 1047].each do |f|
127
+ s.add(buf, hold, s.tone(f, 1.3, vol: 0.05, vibrato: 0.012, decay: 0.7))
128
+ s.add(buf, hold, s.tone(f / 2.0, 1.3, wave: :triangle, vol: 0.08, decay: 0.7))
129
+ end
130
+ s.add(buf, hold, s.tone(131, 1.2, wave: :triangle, vol: 0.18, decay: 0.6))
131
+ 10.times do |j|
132
+ at = r + 0.2 + j * 0.13
133
+ s.add(buf, at - 0.15, s.tone(500 + rng.rand(400), 0.15, wave: :sine, vol: 0.05, slide_to: 1600))
134
+ s.add(buf, at, s.noise(0.14, vol: 0.4, decay: 0.05, smooth: 0.4, rng: rng))
135
+ 14.times { s.add(buf, at + 0.05 + rng.rand * 0.35, s.noise(0.006, vol: 0.18, decay: 0.003, rng: rng)) }
136
+ end
137
+ [buf, 0]
138
+ end
139
+
140
+ # name => [samples, impact offset in seconds]. Shareable so a Ractor can render them.
141
+ PATCHES = Ractor.make_shareable({
142
+ key: -> { [Synth.tone(1400, 0.022, vol: 0.24, decay: 0.008), 0] },
143
+ key_hot: -> { [Synth.tone(1900, 0.024, vol: 0.24, decay: 0.008), 0] },
144
+ delete: -> { [Synth.mix(Synth.noise(0.04, vol: 0.12, decay: 0.012), Synth.tone(500, 0.05, vol: 0.08, slide_to: 180, wave: :triangle)), 0] },
145
+ nice: -> { [Synth.seq(Synth.tone(988, 0.05, wave: :triangle, vol: 0.2, decay: 0.03), Synth.tone(1480, 0.09, wave: :triangle, vol: 0.2, decay: 0.05)), 0] },
146
+ sealed: -> { [Synth.arp([659, 988, 1319], 0.05, wave: :triangle, vol: 0.18, decay: 0.04), 0] },
147
+ lock: -> { [Synth.tone(1200, 0.09, vol: 0.12, slide_to: 2600, decay: 0.05), 0] },
148
+ rewind: -> { [Synth.tone(1400, 0.16, vol: 0.12, slide_to: 350, wave: :triangle), 0] },
149
+ charge: -> { [Synth.tone(300, 0.22, vol: 0.16, slide_to: 1200, wave: :saw), 0] },
150
+ hit: -> { [Synth.seq(Synth.tone(440, 0.05, vol: 0.16, slide_to: 900), Synth.tone(1760, 0.12, wave: :triangle, vol: 0.2, decay: 0.05)), 0.05] },
151
+ puff: -> { [Synth.seq(Synth.tone(900, 0.07, vol: 0.08, slide_to: 300, wave: :triangle), Synth.noise(0.3, vol: 0.45, decay: 0.12, smooth: 0.85)), 0.07] },
152
+ stamp: -> { [Synth.mix(Synth.noise(0.05, vol: 0.5, decay: 0.015), Synth.tone(110, 0.2, wave: :sine, vol: 0.6, slide_to: 60, decay: 0.07)), 0] },
153
+ counter: lambda {
154
+ ticks = Synth.arp((0...14).map { |i| 400 * 2**(i / 7.0) }, 0.038, vol: 0.12, decay: 0.012)
155
+ boom = Synth.mix(Synth.noise(0.45, vol: 0.45, decay: 0.14, smooth: 0.6), Synth.tone(90, 0.4, wave: :sine, vol: 0.6, slide_to: 45, decay: 0.15))
156
+ [Synth.seq(ticks, boom), ticks.size.fdiv(RATE)]
157
+ },
158
+ banner: lambda {
159
+ fan = Synth.arp([523, 659, 784], 0.07, vol: 0.16, decay: 0.05)
160
+ hold = Synth.mix(*[1047, 1319, 1568].map { |f| Synth.tone(f, 0.35, vol: 0.09, vibrato: 0.01) })
161
+ boom = Synth.noise(0.25, vol: 0.3, decay: 0.08, smooth: 0.5)
162
+ [Synth.mix(Synth.seq(fan, hold), Synth.at(fan.size.fdiv(RATE), boom)), fan.size.fdiv(RATE)]
163
+ },
164
+ mega: lambda {
165
+ fan = Synth.arp([523, 659, 784, 1047], 0.07, vol: 0.16, decay: 0.05)
166
+ chord = Synth.mix(*[1047, 1319, 1568, 2093].map { |f| Synth.tone(f, 0.7, vol: 0.07, vibrato: 0.012) })
167
+ blast = Synth.mix(Synth.noise(0.8, vol: 0.5, decay: 0.25, smooth: 0.55), Synth.tone(70, 0.6, wave: :sine, vol: 0.6, slide_to: 35, decay: 0.2))
168
+ r = Random.new(3)
169
+ pops = (0...6).map { |i| Synth.at(0.25 + i * 0.12, Synth.noise(0.12, vol: 0.25, decay: 0.04, rng: r)) }
170
+ head = fan.size.fdiv(RATE)
171
+ [Synth.mix(Synth.seq(fan, chord), Synth.at(head, blast), *pops.map { |p| Synth.at(head, p) }), head]
172
+ },
173
+ rain: -> { [Synth.mix(*(0...16).map { |i| Synth.at(i * 0.045, Synth.tone(600 + (i * 373) % 900, 0.03, vol: 0.22, decay: 0.012)) }), 0] },
174
+ error: -> { [Synth.mix(Synth.tone(220, 0.28, wave: :saw, vol: 0.18, slide_to: 110), Synth.tone(233, 0.28, wave: :saw, vol: 0.14, slide_to: 116)), 0] },
175
+ crack: -> { [Synth.mix(Synth.noise(0.12, vol: 0.55, decay: 0.03), Synth.tone(160, 0.25, wave: :saw, vol: 0.2, slide_to: 70)), 0] },
176
+ snap: -> { [Synth.seq(Synth.tone(1800, 0.03, vol: 0.14), Synth.mix(Synth.noise(0.08, vol: 0.35, decay: 0.02), Synth.tone(300, 0.2, wave: :triangle, vol: 0.14, slide_to: 90))), 0.03] },
177
+ interrupt: -> { [Synth.tone(900, 0.32, wave: :sine, vol: 0.25, slide_to: 90), 0] },
178
+ intro: lambda {
179
+ sweep = Synth.tone(200, 0.3, wave: :saw, vol: 0.12, slide_to: 1600)
180
+ chord = Synth.mix(*[523, 659, 784, 1047].map { |f| Synth.tone(f, 0.6, vol: 0.07, vibrato: 0.01) })
181
+ [Synth.seq(sweep, chord), 0.3]
182
+ },
183
+ result: -> { [Synth.seq(Synth.arp([784, 988, 1175, 1568], 0.08, wave: :triangle, vol: 0.2, decay: 0.06), Synth.tone(2093, 0.4, wave: :triangle, vol: 0.18, decay: 0.2)), 0] },
184
+ finale: ->(k = 1.0) { Sound.finale(k) },
185
+ })
186
+
187
+ # The startup sound first: the loading screen waits for it.
188
+ ORDER = [:intro, *(PATCHES.keys - [:intro])].freeze
189
+
190
+ module_function
191
+
192
+ def wav(samples)
193
+ peak = samples.map(&:abs).max || 0.0
194
+ k = peak > 0.9 ? 0.9 / peak : 1.0 # mixed patches would clip otherwise
195
+ data = samples.map { |s| (s * k * 32_000).round }.pack("s<*")
196
+ ["RIFF", 36 + data.bytesize, "WAVE", "fmt ", 16, 1, 1, RATE, RATE * 2, 2, 16, "data", data.bytesize].pack("a4Va4a4VvvVVvva4V") + data
197
+ end
198
+
199
+ def dir
200
+ @dir ||= File.join(Dir.tmpdir, "dopairb-sfx-#{VERSION}-#{File.mtime(__FILE__).to_i}-#{Process.uid}")
201
+ end
202
+
203
+ # => [path, impact offset, length] (seconds)
204
+ # Patches taking an argument are rendered per time stretch. Rendered
205
+ # sounds are reused across sessions; the .meta file is written last.
206
+ def file(name, stretch = 1.0)
207
+ key = key_for(name, stretch)
208
+ @files ||= {}
209
+ @files[key] ||= cached(key) || render(name, stretch, key)
210
+ end
211
+
212
+ def key_for(name, stretch)
213
+ PATCHES.fetch(name).arity.zero? ? name.to_s : "#{name}-#{stretch.round(2)}"
214
+ end
215
+
216
+ def cached(key)
217
+ path = File.join(dir, "#{key}.wav")
218
+ impact, len = File.read("#{path}.meta").split.map(&:to_f)
219
+ [path, impact, len] if len && File.exist?(path)
220
+ rescue SystemCallError
221
+ nil
222
+ end
223
+
224
+ # Pure (no module state) so it can run inside a Ractor.
225
+ def render(name, stretch, key, dir = self.dir)
226
+ patch = PATCHES.fetch(name)
227
+ samples, impact = patch.arity.zero? ? patch.call : patch.call(stretch.round(2))
228
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
229
+ path = File.join(dir, "#{key}.wav")
230
+ len = samples.size.fdiv(RATE)
231
+ atomic_write(path, wav(samples))
232
+ atomic_write("#{path}.meta", "#{impact} #{len}\n")
233
+ [path, impact, len]
234
+ end
235
+
236
+ def atomic_write(path, data)
237
+ tmp = "#{path}.#{Process.pid}.tmp"
238
+ File.binwrite(tmp, data)
239
+ File.rename(tmp, path)
240
+ end
241
+
242
+ # Seconds from start until the sound has finished, measured from its impact.
243
+ def tail(name, stretch = 1.0)
244
+ _, impact, len = file(name, stretch)
245
+ len - impact
246
+ end
247
+
248
+ def which(cmd)
249
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, cmd) }.find { |p| File.executable?(p) && !File.directory?(p) }
250
+ end
251
+
252
+ def wsl?
253
+ @wsl = File.read("/proc/version").match?(/microsoft/i) if @wsl.nil?
254
+ @wsl
255
+ rescue StandardError
256
+ @wsl = false
257
+ end
258
+
259
+ # [kind, argv-prefix] or nil
260
+ def backend
261
+ return @backend if defined?(@backend)
262
+ @backend =
263
+ if (p = which("paplay")) && (ENV["PULSE_SERVER"] || !wsl? || File.exist?("/mnt/wslg/PulseServer"))
264
+ [:fast, [p]]
265
+ elsif (p = which("afplay"))
266
+ [:fast, [p]]
267
+ elsif (p = which("aplay"))
268
+ [:fast, [p, "-q"]]
269
+ elsif wsl? && (p = which("powershell.exe")) && which("wslpath")
270
+ [:slow, [p, "-NoProfile", "-NonInteractive", "-Command"]]
271
+ end
272
+ end
273
+
274
+ def reset_backend
275
+ remove_instance_variable(:@backend) if defined?(@backend)
276
+ end
277
+
278
+ def available? = !backend.nil?
279
+
280
+ # Per-keystroke sounds only make sense with a low-latency player.
281
+ def fast? = backend&.first == :fast
282
+
283
+ @last = {}
284
+ @children = []
285
+
286
+ # Start playing so that the sound's impact lands `delay` seconds from now.
287
+ def play(name, delay: 0.0, stretch: 1.0)
288
+ kind, argv = backend
289
+ return false unless kind
290
+ return false if kind == :slow && %i[key key_hot delete].include?(name)
291
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
292
+ return false if @last[name] && now - @last[name] < 0.04
293
+ @children.reject! { |pid| Process.waitpid(pid, Process::WNOHANG) rescue true }
294
+ return false if @children.size >= 4
295
+ @last[name] = now
296
+ started = now
297
+ Thread.new do
298
+ Thread.current.report_on_exception = false
299
+ path, impact = file(name, stretch)
300
+ cmd = if kind == :slow
301
+ win = OutputTap.quietly { IO.popen(["wslpath", "-w", path], &:read) }.strip
302
+ argv + ["(New-Object Media.SoundPlayer '#{win}').PlaySync()"]
303
+ else
304
+ argv + [path]
305
+ end
306
+ spent = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
307
+ wait = delay - impact - spent - (kind == :slow ? 0.3 : 0.03)
308
+ sleep wait if wait > 0.01
309
+ pid = OutputTap.quietly { Process.spawn(*cmd, in: File::NULL, out: File::NULL, err: File::NULL, pgroup: true) }
310
+ @children << pid
311
+ rescue StandardError => e
312
+ Dopairb.debug(e)
313
+ end
314
+ true
315
+ rescue StandardError => e
316
+ Dopairb.debug(e)
317
+ false
318
+ end
319
+
320
+ def ready?(name, stretch = 1.0) = !cached(key_for(name, stretch)).nil?
321
+
322
+ # Render every missing sound in the background, the startup sound first.
323
+ # A Ractor runs the synthesis in parallel with the REPL (no GVL contention,
324
+ # so typing stays smooth); without Ractor support, a thread does it.
325
+ def warm_up(stretch = 1.0)
326
+ return unless available?
327
+ return @warming if @warming&.alive?
328
+ missing = ORDER.reject { |n| ready?(n, stretch) }
329
+ return if missing.empty?
330
+ FileUtils.mkdir_p(dir)
331
+ @warming = start_ractor(missing, stretch) || Thread.new do
332
+ Thread.current.report_on_exception = false
333
+ missing.each { |n| file(n, stretch) }
334
+ end
335
+ rescue StandardError => e
336
+ Dopairb.debug(e)
337
+ nil
338
+ end
339
+
340
+ # => a Thread that finishes when the Ractor has rendered everything, or nil
341
+ def start_ractor(names, stretch)
342
+ return nil unless defined?(Ractor)
343
+ args = Ractor.make_shareable([dir.dup, names.map { |n| [n, key_for(n, stretch)] }, stretch.round(2)])
344
+ prev = Warning[:experimental]
345
+ Warning[:experimental] = false # "Ractor API is experimental" must not reach the user
346
+ r = begin
347
+ Ractor.new(args) do |(d, jobs, k)|
348
+ jobs.each { |n, key| Sound.render(n, k, key, d) }
349
+ :done
350
+ rescue Exception # rubocop:disable Lint/RescueException
351
+ :failed
352
+ end
353
+ ensure
354
+ Warning[:experimental] = prev
355
+ end
356
+ Thread.new do
357
+ Thread.current.report_on_exception = false
358
+ r.value
359
+ end
360
+ rescue StandardError => e
361
+ Dopairb.debug(e)
362
+ nil
363
+ end
364
+ end
365
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dopairb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/dopairb.rb CHANGED
@@ -23,9 +23,11 @@ require_relative "dopairb/scene"
23
23
  require_relative "dopairb/scenes/success"
24
24
  require_relative "dopairb/scenes/failure"
25
25
  require_relative "dopairb/scenes/session"
26
+ require_relative "dopairb/scenes/finale"
26
27
  require_relative "dopairb/director"
27
28
  require_relative "dopairb/output_tap"
28
29
  require_relative "dopairb/charge_line"
30
+ require_relative "dopairb/sound"
29
31
  require_relative "dopairb/input_fx"
30
32
  require_relative "dopairb/reline_adapter"
31
33
  require_relative "dopairb/irb_adapter"
@@ -58,6 +60,11 @@ module Dopairb
58
60
  if IrbAdapter.install
59
61
  RelineAdapter.install(@session.fx)
60
62
  OutputTap.install
63
+ if active?
64
+ # decided before warming up: the first run shows the loading screen
65
+ @session.cold_start = Sound.available? && !Sound.ready?(:intro, config.duration)
66
+ Sound.warm_up(config.duration)
67
+ end
61
68
  else
62
69
  warn "dopairb: IRB #{defined?(IRB::VERSION) ? IRB::VERSION : '?'} is not supported; running plain IRB"
63
70
  end
@@ -81,15 +88,16 @@ module Dopairb
81
88
  "normal" => { intensity: :normal },
82
89
  "max" => { intensity: :max },
83
90
  "calm" => { intensity: :low, flash: :off, duration: 0.7 },
84
- "party" => { intensity: :max, flash: :full, sound: true, duration: 1.0 },
91
+ "party" => { intensity: :max, flash: :full, sound: :sfx, duration: 1.0 },
85
92
  }.freeze
86
93
 
87
94
  HELP = <<~TXT
88
95
  dopa show settings and session stats
89
96
  dopa off|low|normal|max
90
97
  dopa calm low intensity, no flash, shorter
91
- dopa party max intensity, full-screen flash, bell
98
+ dopa party max intensity, full-screen flash, sound effects
92
99
  dopa demo play every effect once
100
+ dopa loading show the loading screen
93
101
  dopa KEY=VALUE ... change one setting, e.g. `dopa flash=off duration=0.5`
94
102
 
95
103
  settings:
@@ -101,13 +109,18 @@ module Dopairb
101
109
  s = @session&.game
102
110
  puts "dopairb #{VERSION} (#{active? ? 'active' : 'inactive: not a terminal or intensity=off'})"
103
111
  puts config.describe
112
+ puts " sound player: #{Sound.backend ? Sound.backend[1].first : '(none; bell only)'} cache: #{Sound.dir}"
104
113
  puts " session: #{s.evals} evals, #{s.successes} hits, #{s.failures} errors, max combo #{s.max_combo}, score #{Fx.number_with_commas(s.score)}" if s
105
114
  elsif words == ["help"]
106
115
  puts HELP + config.describe
107
116
  elsif words == ["demo"]
108
117
  Demo.run(self)
118
+ elsif words == ["loading"]
119
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
+ @session&.director&.loading { Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 > 2.5 } if active?
109
121
  elsif words.size == 1 && PRESETS.key?(words[0])
110
122
  PRESETS[words[0]].each { |k, v| config.set(k, v) }
123
+ Sound.warm_up(config.duration) if active?
111
124
  puts "dopairb: #{words[0]}"
112
125
  else
113
126
  words.each do |w|
@@ -115,6 +128,7 @@ module Dopairb
115
128
  raise ArgumentError, "expected KEY=VALUE, got #{w.inspect}" unless v
116
129
  config.set(k, v)
117
130
  end
131
+ Sound.warm_up(config.duration) if active?
118
132
  puts "dopairb: " + words.join(" ")
119
133
  end
120
134
  nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dopairb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi Sasada
@@ -80,9 +80,11 @@ files:
80
80
  - lib/dopairb/reline_adapter.rb
81
81
  - lib/dopairb/scene.rb
82
82
  - lib/dopairb/scenes/failure.rb
83
+ - lib/dopairb/scenes/finale.rb
83
84
  - lib/dopairb/scenes/session.rb
84
85
  - lib/dopairb/scenes/success.rb
85
86
  - lib/dopairb/session.rb
87
+ - lib/dopairb/sound.rb
86
88
  - lib/dopairb/stage.rb
87
89
  - lib/dopairb/term.rb
88
90
  - lib/dopairb/version.rb