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.
- checksums.yaml +4 -4
- data/README.md +24 -3
- data/lib/dopairb/canvas.rb +39 -16
- data/lib/dopairb/cli.rb +2 -2
- data/lib/dopairb/config.rb +7 -3
- data/lib/dopairb/demo.rb +13 -2
- data/lib/dopairb/director.rb +40 -7
- data/lib/dopairb/game.rb +7 -3
- data/lib/dopairb/input_fx.rb +15 -1
- data/lib/dopairb/output_tap.rb +10 -0
- data/lib/dopairb/scene.rb +7 -0
- data/lib/dopairb/scenes/failure.rb +6 -0
- data/lib/dopairb/scenes/finale.rb +286 -0
- data/lib/dopairb/scenes/session.rb +94 -0
- data/lib/dopairb/scenes/success.rb +11 -0
- data/lib/dopairb/session.rb +8 -1
- data/lib/dopairb/sound.rb +365 -0
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +16 -2
- metadata +3 -1
|
@@ -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
|
data/lib/dopairb/version.rb
CHANGED
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:
|
|
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,
|
|
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.
|
|
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
|