dopairb 0.1.0 → 0.4.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 +63 -10
- data/lib/dopairb/canvas.rb +39 -16
- data/lib/dopairb/cli.rb +5 -7
- data/lib/dopairb/config.rb +11 -5
- data/lib/dopairb/demo.rb +22 -4
- data/lib/dopairb/director.rb +75 -12
- data/lib/dopairb/fx.rb +23 -0
- data/lib/dopairb/gallery/impressionism.rb +818 -0
- data/lib/dopairb/gallery/japan.rb +693 -0
- data/lib/dopairb/gallery/modern_ancient.rb +774 -0
- data/lib/dopairb/gallery/renaissance.rb +829 -0
- data/lib/dopairb/gallery/romantic.rb +703 -0
- data/lib/dopairb/gallery.rb +349 -0
- data/lib/dopairb/game.rb +90 -9
- data/lib/dopairb/input_fx.rb +78 -10
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/output_tap.rb +10 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +12 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +7 -0
- data/lib/dopairb/scenes/finale.rb +326 -0
- data/lib/dopairb/scenes/session.rb +115 -3
- data/lib/dopairb/scenes/success.rb +22 -4
- data/lib/dopairb/session.rb +57 -6
- data/lib/dopairb/sound.rb +420 -0
- data/lib/dopairb/term.rb +3 -1
- data/lib/dopairb/tour.rb +89 -0
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +73 -3
- data/spec.md +3 -3
- metadata +12 -1
data/lib/dopairb/session.rb
CHANGED
|
@@ -4,28 +4,66 @@ module Dopairb
|
|
|
4
4
|
# One interactive session: owns the game state and ties the evaluation
|
|
5
5
|
# cycle to the director.
|
|
6
6
|
class Session
|
|
7
|
-
attr_reader :config, :game, :director, :fx
|
|
7
|
+
attr_reader :config, :game, :director, :fx, :collected
|
|
8
|
+
attr_accessor :cold_start
|
|
8
9
|
|
|
9
10
|
def initialize(config)
|
|
10
11
|
@config = config
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
# DOPAIRB_SEED makes the luck (crits, jackpots, sparks) repeatable, e.g. for recordings.
|
|
13
|
+
seed = ENV["DOPAIRB_SEED"]
|
|
14
|
+
@game = seed ? Game.new(rng: Random.new(seed.to_i)) : Game.new
|
|
15
|
+
@director = seed ? Director.new(config, rng: Random.new(seed.to_i + 1)) : Director.new(config)
|
|
13
16
|
@fx = InputFx.new(config, @game)
|
|
14
17
|
@finished = false
|
|
18
|
+
@introduced = false
|
|
19
|
+
@profile = nil
|
|
20
|
+
@collected = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Loaded on first use, and only for interactive sessions.
|
|
24
|
+
def profile
|
|
25
|
+
@profile ||= begin
|
|
26
|
+
prof = active? ? Profile.load : Profile.new
|
|
27
|
+
@start = { xp: prof.xp, level: prof.level, best_score: prof.best_score, sessions: prof.sessions }
|
|
28
|
+
@game.career(xp: prof.xp, best_combo: prof.best_combo.positive? ? prof.best_combo : nil) if prof.persistent?
|
|
29
|
+
prof
|
|
30
|
+
end
|
|
15
31
|
end
|
|
16
32
|
|
|
17
33
|
def active?
|
|
18
|
-
!@config.off? && Term.interactive?
|
|
34
|
+
!@config.off? && Term.interactive? && (@config.binding_irb || !IrbAdapter.from_binding?)
|
|
19
35
|
end
|
|
20
36
|
|
|
37
|
+
# Once per process: from the dopairb command before IRB starts, or for
|
|
38
|
+
# `Dopairb.enable` in .irbrc right before the first prompt.
|
|
21
39
|
def intro
|
|
22
|
-
|
|
40
|
+
return if @introduced
|
|
41
|
+
@introduced = true
|
|
42
|
+
return unless active?
|
|
43
|
+
prof = profile
|
|
44
|
+
grew = prof.visit
|
|
45
|
+
prof.save if grew
|
|
46
|
+
Sound.warm_up(@config.duration)
|
|
47
|
+
cold = @cold_start || (Sound.available? && !Sound.ready?(:intro, @config.duration))
|
|
48
|
+
if @config.intro && @config.motion && cold
|
|
49
|
+
@director.loading { Sound.ready?(:intro, @config.duration) }
|
|
50
|
+
end
|
|
51
|
+
@director.intro(prof.persistent? ? { level: prof.level, streak: prof.streak, streak_up: grew && prof.streak >= 2 } : nil)
|
|
23
52
|
end
|
|
24
53
|
|
|
25
54
|
def finish
|
|
26
55
|
return if @finished
|
|
27
56
|
@finished = true
|
|
28
|
-
|
|
57
|
+
return unless active? && @game.evals > 0
|
|
58
|
+
prof = profile
|
|
59
|
+
stats = @game.stats
|
|
60
|
+
if prof.persistent?
|
|
61
|
+
stats = stats.merge(xp_before: @start[:xp], level_before: @start[:level], level: @game.level, xp: @game.xp,
|
|
62
|
+
best_before: @start[:best_score], first_session: @start[:sessions].zero?)
|
|
63
|
+
end
|
|
64
|
+
prof.visit
|
|
65
|
+
@profile = prof.finish(stats, collected: @collected)
|
|
66
|
+
@director.outro(stats.merge(gallery: @profile.gallery.size, gallery_total: Gallery::PIECES.size))
|
|
29
67
|
end
|
|
30
68
|
|
|
31
69
|
def around_eval(code, last_value)
|
|
@@ -61,7 +99,9 @@ module Dopairb
|
|
|
61
99
|
shot = clean ? input : nil
|
|
62
100
|
if outcome == :success
|
|
63
101
|
info = Probe.value(last_value.call, code).merge(out_lines: lines)
|
|
102
|
+
profile
|
|
64
103
|
event = @game.success(info, code: code, out_lines: lines)
|
|
104
|
+
award_art(event) if %i[level_up jackpot art_drop].any? { |f| event.flag?(f) } && @game.leveling?
|
|
65
105
|
pre = elapsed > 0.2 || shot.nil? ? 0.0 : [@config.charge, 0.3].min
|
|
66
106
|
@director.play_event(event, input: shot, pre: pre)
|
|
67
107
|
else
|
|
@@ -74,6 +114,17 @@ module Dopairb
|
|
|
74
114
|
Dopairb.debug(e)
|
|
75
115
|
end
|
|
76
116
|
|
|
117
|
+
# Level-ups, jackpots and lucky drops pay out a masterpiece, a new one while any are missing.
|
|
118
|
+
def award_art(event)
|
|
119
|
+
owned = profile.gallery + @collected
|
|
120
|
+
piece = Gallery.pick(@director.rng, owned)
|
|
121
|
+
fresh = !owned.include?(piece.id)
|
|
122
|
+
@collected << piece.id if fresh
|
|
123
|
+
event.info[:art] = piece.id
|
|
124
|
+
event.info[:art_new] = fresh
|
|
125
|
+
event.info[:gallery] = [(owned + [piece.id]).uniq.size, Gallery::PIECES.size]
|
|
126
|
+
end
|
|
127
|
+
|
|
77
128
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
129
|
end
|
|
79
130
|
end
|
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
bits: lambda {
|
|
185
|
+
up = Synth.arp([523, 659, 784, 1047, 1319, 1568, 2093], 0.035, vol: 0.13, decay: 0.03)
|
|
186
|
+
[Synth.seq(up, Synth.tone(2093, 0.18, vol: 0.1, vibrato: 0.02, decay: 0.08)), 0]
|
|
187
|
+
},
|
|
188
|
+
crit: lambda {
|
|
189
|
+
shing = Synth.tone(2400, 0.1, wave: :saw, vol: 0.12, slide_to: 5200, decay: 0.05)
|
|
190
|
+
thud = Synth.mix(Synth.noise(0.12, vol: 0.55, decay: 0.03), Synth.tone(140, 0.25, wave: :sine, vol: 0.7, slide_to: 60, decay: 0.08))
|
|
191
|
+
sting = Synth.mix(*[880, 1109, 1319].map { |f| Synth.tone(f, 0.3, wave: :square, vol: 0.06, decay: 0.12) })
|
|
192
|
+
[Synth.mix(shing, Synth.at(0.1, thud), Synth.at(0.12, sting)), 0.1]
|
|
193
|
+
},
|
|
194
|
+
jackpot: lambda {
|
|
195
|
+
r = Random.new(9)
|
|
196
|
+
buf = []
|
|
197
|
+
t = 0.0
|
|
198
|
+
while t < 0.95
|
|
199
|
+
Synth.add(buf, t, Synth.tone(700 + r.rand(500), 0.02, vol: 0.1, decay: 0.008))
|
|
200
|
+
t += 0.045
|
|
201
|
+
end
|
|
202
|
+
[0.45, 0.7, 0.95].each_with_index do |at, i|
|
|
203
|
+
Synth.add(buf, at, Synth.mix(Synth.noise(0.05, vol: 0.4, decay: 0.015, rng: r), Synth.tone(160 + i * 40, 0.15, wave: :sine, vol: 0.5, slide_to: 80, decay: 0.05)))
|
|
204
|
+
Synth.add(buf, at, Synth.tone([784, 988, 1175][i], 0.12, wave: :triangle, vol: 0.18, decay: 0.06))
|
|
205
|
+
end
|
|
206
|
+
Synth.add(buf, 0.95, Synth.boom(0.8, vol: 0.6, low: 75))
|
|
207
|
+
Synth.add(buf, 1.0, Synth.arp([1047, 1319, 1568, 2093, 1568, 2093, 2637], 0.07, vol: 0.12, decay: 0.05))
|
|
208
|
+
40.times { Synth.add(buf, 1.0 + r.rand * 1.2, Synth.tone(2600 + r.rand(1800), 0.06, wave: :sine, vol: 0.08, decay: 0.02)) }
|
|
209
|
+
[buf, 0.95]
|
|
210
|
+
},
|
|
211
|
+
levelup: lambda {
|
|
212
|
+
buf = []
|
|
213
|
+
Synth.add(buf, 0, Synth.tone(150, 0.22, wave: :saw, vol: 0.12, slide_to: 900))
|
|
214
|
+
Synth.add(buf, 0.22, Synth.boom(0.7, vol: 0.6))
|
|
215
|
+
fan = Synth.arp([523, 659, 784, 1047, 784, 1047, 1319, 1568], 0.06, vol: 0.13, decay: 0.05)
|
|
216
|
+
Synth.add(buf, 0.24, fan)
|
|
217
|
+
[1047, 1319, 1568, 2093].each { |f| Synth.add(buf, 0.72, Synth.tone(f, 0.5, vol: 0.06, vibrato: 0.015, decay: 0.3)) }
|
|
218
|
+
# the unveiling: a gong and a shimmering chord
|
|
219
|
+
Synth.add(buf, 1.0, Synth.tone(98, 1.6, wave: :sine, vol: 0.45, decay: 0.7, vibrato: 0.004))
|
|
220
|
+
Synth.add(buf, 1.0, Synth.tone(196, 1.2, wave: :triangle, vol: 0.12, decay: 0.5))
|
|
221
|
+
Synth.add(buf, 1.45, Synth.noise(0.5, vol: 0.25, decay: 0.15, smooth: 0.5))
|
|
222
|
+
[523, 659, 784, 1047, 1319].each_with_index do |f, i|
|
|
223
|
+
Synth.add(buf, 1.45 + i * 0.05, Synth.tone(f, 1.4, vol: 0.05, vibrato: 0.01, decay: 0.6))
|
|
224
|
+
end
|
|
225
|
+
r = Random.new(4)
|
|
226
|
+
16.times { Synth.add(buf, 1.6 + r.rand * 1.4, Synth.tone(3000 + r.rand(2000), 0.05, wave: :sine, vol: 0.06, decay: 0.015)) }
|
|
227
|
+
[buf, 0.22]
|
|
228
|
+
},
|
|
229
|
+
finale: ->(k = 1.0) { Sound.finale(k) },
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
# The startup sound first: the loading screen waits for it.
|
|
233
|
+
ORDER = [:intro, *(PATCHES.keys - [:intro])].freeze
|
|
234
|
+
|
|
235
|
+
module_function
|
|
236
|
+
|
|
237
|
+
def wav(samples)
|
|
238
|
+
peak = samples.map(&:abs).max || 0.0
|
|
239
|
+
k = peak > 0.9 ? 0.9 / peak : 1.0 # mixed patches would clip otherwise
|
|
240
|
+
data = samples.map { |s| (s * k * 32_000).round }.pack("s<*")
|
|
241
|
+
["RIFF", 36 + data.bytesize, "WAVE", "fmt ", 16, 1, 1, RATE, RATE * 2, 2, 16, "data", data.bytesize].pack("a4Va4a4VvvVVvva4V") + data
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def dir
|
|
245
|
+
@dir ||= File.join(Dir.tmpdir, "dopairb-sfx-#{VERSION}-#{File.mtime(__FILE__).to_i}-#{Process.uid}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# => [path, impact offset, length] (seconds)
|
|
249
|
+
# Patches taking an argument are rendered per time stretch. Rendered
|
|
250
|
+
# sounds are reused across sessions; the .meta file is written last.
|
|
251
|
+
def file(name, stretch = 1.0)
|
|
252
|
+
key = key_for(name, stretch)
|
|
253
|
+
@files ||= {}
|
|
254
|
+
@files[key] ||= cached(key) || render(name, stretch, key)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def key_for(name, stretch)
|
|
258
|
+
PATCHES.fetch(name).arity.zero? ? name.to_s : "#{name}-#{stretch.round(2)}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def cached(key)
|
|
262
|
+
path = File.join(dir, "#{key}.wav")
|
|
263
|
+
impact, len = File.read("#{path}.meta").split.map(&:to_f)
|
|
264
|
+
[path, impact, len] if len && File.exist?(path)
|
|
265
|
+
rescue SystemCallError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Pure (no module state) so it can run inside a Ractor.
|
|
270
|
+
def render(name, stretch, key, dir = self.dir)
|
|
271
|
+
patch = PATCHES.fetch(name)
|
|
272
|
+
samples, impact = patch.arity.zero? ? patch.call : patch.call(stretch.round(2))
|
|
273
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
274
|
+
path = File.join(dir, "#{key}.wav")
|
|
275
|
+
len = samples.size.fdiv(RATE)
|
|
276
|
+
atomic_write(path, wav(samples))
|
|
277
|
+
atomic_write("#{path}.meta", "#{impact} #{len}\n")
|
|
278
|
+
[path, impact, len]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def atomic_write(path, data)
|
|
282
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
283
|
+
File.binwrite(tmp, data)
|
|
284
|
+
File.rename(tmp, path)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Seconds from start until the sound has finished, measured from its impact.
|
|
288
|
+
def tail(name, stretch = 1.0)
|
|
289
|
+
_, impact, len = file(name, stretch)
|
|
290
|
+
len - impact
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def which(cmd)
|
|
294
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).map { |d| File.join(d, cmd) }.find { |p| File.executable?(p) && !File.directory?(p) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def wsl?
|
|
298
|
+
@wsl = File.read("/proc/version").match?(/microsoft/i) if @wsl.nil?
|
|
299
|
+
@wsl
|
|
300
|
+
rescue StandardError
|
|
301
|
+
@wsl = false
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# [kind, argv-prefix] or nil
|
|
305
|
+
def backend
|
|
306
|
+
return @backend if defined?(@backend)
|
|
307
|
+
@backend =
|
|
308
|
+
if (log = ENV["DOPAIRB_SOUND_LOG"]) && !log.empty?
|
|
309
|
+
[:log, [log]] # recordings: write "<wall clock of impact> <wav>" lines instead of playing
|
|
310
|
+
elsif (p = which("paplay")) && (ENV["PULSE_SERVER"] || !wsl? || File.exist?("/mnt/wslg/PulseServer"))
|
|
311
|
+
[:fast, [p]]
|
|
312
|
+
elsif (p = which("afplay"))
|
|
313
|
+
[:fast, [p]]
|
|
314
|
+
elsif (p = which("aplay"))
|
|
315
|
+
[:fast, [p, "-q"]]
|
|
316
|
+
elsif wsl? && (p = which("powershell.exe")) && which("wslpath")
|
|
317
|
+
[:slow, [p, "-NoProfile", "-NonInteractive", "-Command"]]
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def reset_backend
|
|
322
|
+
remove_instance_variable(:@backend) if defined?(@backend)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def available? = !backend.nil?
|
|
326
|
+
|
|
327
|
+
# Per-keystroke sounds only make sense with a low-latency player.
|
|
328
|
+
def fast? = %i[fast log].include?(backend&.first)
|
|
329
|
+
|
|
330
|
+
@last = {}
|
|
331
|
+
@children = []
|
|
332
|
+
|
|
333
|
+
# Start playing so that the sound's impact lands `delay` seconds from now.
|
|
334
|
+
def play(name, delay: 0.0, stretch: 1.0)
|
|
335
|
+
kind, argv = backend
|
|
336
|
+
return false unless kind
|
|
337
|
+
return false if kind == :slow && %i[key key_hot delete].include?(name)
|
|
338
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
339
|
+
return false if @last[name] && now - @last[name] < 0.04
|
|
340
|
+
@children.reject! { |pid| Process.waitpid(pid, Process::WNOHANG) rescue true }
|
|
341
|
+
return false if @children.size >= 4
|
|
342
|
+
@last[name] = now
|
|
343
|
+
started = now
|
|
344
|
+
return log_play(argv[0], name, delay, stretch) if kind == :log
|
|
345
|
+
Thread.new do
|
|
346
|
+
Thread.current.report_on_exception = false
|
|
347
|
+
path, impact = file(name, stretch)
|
|
348
|
+
cmd = if kind == :slow
|
|
349
|
+
win = OutputTap.quietly { IO.popen(["wslpath", "-w", path], &:read) }.strip
|
|
350
|
+
argv + ["(New-Object Media.SoundPlayer '#{win}').PlaySync()"]
|
|
351
|
+
else
|
|
352
|
+
argv + [path]
|
|
353
|
+
end
|
|
354
|
+
spent = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
355
|
+
wait = delay - impact - spent - (kind == :slow ? 0.3 : 0.03)
|
|
356
|
+
sleep wait if wait > 0.01
|
|
357
|
+
pid = OutputTap.quietly { Process.spawn(*cmd, in: File::NULL, out: File::NULL, err: File::NULL, pgroup: true) }
|
|
358
|
+
@children << pid
|
|
359
|
+
rescue StandardError => e
|
|
360
|
+
Dopairb.debug(e)
|
|
361
|
+
end
|
|
362
|
+
true
|
|
363
|
+
rescue StandardError => e
|
|
364
|
+
Dopairb.debug(e)
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def log_play(log, name, delay, stretch)
|
|
369
|
+
at = Process.clock_gettime(Process::CLOCK_REALTIME) + delay
|
|
370
|
+
path, impact = file(name, stretch)
|
|
371
|
+
File.open(log, "a") { |f| f.puts format("%.4f %s", at - impact, path) }
|
|
372
|
+
true
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def ready?(name, stretch = 1.0) = !cached(key_for(name, stretch)).nil?
|
|
376
|
+
|
|
377
|
+
# Render every missing sound in the background, the startup sound first.
|
|
378
|
+
# A Ractor runs the synthesis in parallel with the REPL (no GVL contention,
|
|
379
|
+
# so typing stays smooth); without Ractor support, a thread does it.
|
|
380
|
+
def warm_up(stretch = 1.0)
|
|
381
|
+
return unless available?
|
|
382
|
+
return @warming if @warming&.alive?
|
|
383
|
+
missing = ORDER.reject { |n| ready?(n, stretch) }
|
|
384
|
+
return if missing.empty?
|
|
385
|
+
FileUtils.mkdir_p(dir)
|
|
386
|
+
@warming = start_ractor(missing, stretch) || Thread.new do
|
|
387
|
+
Thread.current.report_on_exception = false
|
|
388
|
+
missing.each { |n| file(n, stretch) }
|
|
389
|
+
end
|
|
390
|
+
rescue StandardError => e
|
|
391
|
+
Dopairb.debug(e)
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# => a Thread that finishes when the Ractor has rendered everything, or nil
|
|
396
|
+
def start_ractor(names, stretch)
|
|
397
|
+
return nil unless defined?(Ractor)
|
|
398
|
+
args = Ractor.make_shareable([dir.dup, names.map { |n| [n, key_for(n, stretch)] }, stretch.round(2)])
|
|
399
|
+
prev = Warning[:experimental]
|
|
400
|
+
Warning[:experimental] = false # "Ractor API is experimental" must not reach the user
|
|
401
|
+
r = begin
|
|
402
|
+
Ractor.new(args) do |(d, jobs, k)|
|
|
403
|
+
jobs.each { |n, key| Sound.render(n, k, key, d) }
|
|
404
|
+
:done
|
|
405
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
406
|
+
:failed
|
|
407
|
+
end
|
|
408
|
+
ensure
|
|
409
|
+
Warning[:experimental] = prev
|
|
410
|
+
end
|
|
411
|
+
Thread.new do
|
|
412
|
+
Thread.current.report_on_exception = false
|
|
413
|
+
r.value
|
|
414
|
+
end
|
|
415
|
+
rescue StandardError => e
|
|
416
|
+
Dopairb.debug(e)
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
data/lib/dopairb/term.rb
CHANGED
|
@@ -85,9 +85,11 @@ module Dopairb
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
# First candidate that renders one column wide on this terminal.
|
|
88
|
+
# A nil candidate means "none of the above": glyph("▀", nil) may be nil.
|
|
88
89
|
def glyph(*candidates)
|
|
89
90
|
@glyphs ||= {}
|
|
90
|
-
@glyphs[candidates]
|
|
91
|
+
return @glyphs[candidates] if @glyphs.key?(candidates)
|
|
92
|
+
@glyphs[candidates] = candidates.find { |c| c.nil? || c.ascii_only? || (utf8? && c.each_char.all? { |ch| char_width(ch) == 1 }) } || candidates.last
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def reset_glyphs
|
data/lib/dopairb/tour.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# `dopa gallery tour`: a full-screen slideshow of the whole collection.
|
|
5
|
+
# Pieces you own are shown in color; the rest are silhouettes to hunt for.
|
|
6
|
+
# → / space: next, ←: back, q: leave. Slides advance on their own.
|
|
7
|
+
module Tour
|
|
8
|
+
SLIDE = 4.0
|
|
9
|
+
REVEAL = 0.35
|
|
10
|
+
FPS = 30.0
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def run(config, owned, start: 0)
|
|
15
|
+
pieces = Gallery::PIECES
|
|
16
|
+
depth = Term.depth(config)
|
|
17
|
+
i = start % pieces.size
|
|
18
|
+
stage = AltStage.new(Term.rows)
|
|
19
|
+
Term.raw do
|
|
20
|
+
stage.open
|
|
21
|
+
begin
|
|
22
|
+
loop do
|
|
23
|
+
step = show(stage, pieces, i, owned, depth, config)
|
|
24
|
+
break if step == :quit
|
|
25
|
+
i = (i + step) % pieces.size
|
|
26
|
+
end
|
|
27
|
+
ensure
|
|
28
|
+
stage.close
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
rescue Interrupt
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Animate one slide; => how many slides to move (+1 on timeout) or :quit.
|
|
36
|
+
def show(stage, pieces, i, owned, depth, config)
|
|
37
|
+
t0 = now
|
|
38
|
+
loop do
|
|
39
|
+
t = now - t0
|
|
40
|
+
stage.draw(frame(pieces[i], i, pieces.size, owned, depth, [t / REVEAL, 1.0].min, t))
|
|
41
|
+
step = read_key(t < REVEAL ? 1 / FPS : [SLIDE * config.duration - t, 0.05].max)
|
|
42
|
+
return step if step
|
|
43
|
+
return 1 if t >= SLIDE * config.duration
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def frame(piece, i, total, owned, depth, reveal, t)
|
|
48
|
+
rows, cols = Term.size
|
|
49
|
+
w = cols - 1
|
|
50
|
+
c = Canvas.new(w, rows)
|
|
51
|
+
have = owned.include?(piece.id)
|
|
52
|
+
star = Term.glyph("✦", "*")
|
|
53
|
+
head = "#{star} DOPA IRB GALLERY #{star} #{i + 1} / #{total} collected #{owned.size}"
|
|
54
|
+
c.put_center(0, head, Color.rainbow(t * 0.3), bold: true)
|
|
55
|
+
aw, ah = Gallery.fit(piece, w - 6, rows - 6)
|
|
56
|
+
ax = (w - aw) / 2
|
|
57
|
+
ay = 2
|
|
58
|
+
frame_col = have ? Color.mix([255, 240, 170], [190, 130, 30], (Math.sin(t * 3) + 1) / 2) : [70, 70, 90]
|
|
59
|
+
c.fill_bg(frame_col, ax - 2, ay - 1, aw + 4, ah / 2 + 2)
|
|
60
|
+
c.fill_bg([20, 20, 28], ax - 1, ay, aw + 2, ah / 2)
|
|
61
|
+
Gallery.draw(c, piece, ax, ay, aw, ah, reveal: reveal, depth: depth) do |top, bot, _x, _r|
|
|
62
|
+
have ? [top, bot] : [shadow(top), shadow(bot)]
|
|
63
|
+
end
|
|
64
|
+
cap = have ? "\"#{piece.title}\" #{piece.artist}, #{piece.year}" : "? ? ? (by #{piece.artist})"
|
|
65
|
+
c.put_center(ay + ah / 2 + 1, cap, have ? [240, 230, 210] : [150, 150, 175], bold: have)
|
|
66
|
+
c.put_center(rows - 1, "->/space next <- back q quit", [120, 120, 140])
|
|
67
|
+
c.render(depth)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Uncollected pieces keep their shapes but lose their colors.
|
|
71
|
+
def shadow(color)
|
|
72
|
+
Color.mix([12, 12, 22], [80, 80, 110], Color.luminance(color) / 255.0)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Keys can arrive several at once (a held arrow key): all of them count.
|
|
76
|
+
# => :quit, a step (+n / -n), or nil when nothing was typed.
|
|
77
|
+
def read_key(timeout)
|
|
78
|
+
return nil unless Term.input.wait_readable(timeout)
|
|
79
|
+
s = Term.input.read_nonblock(256, exception: false)
|
|
80
|
+
return nil unless String === s
|
|
81
|
+
keys = s.scan(/\e[\[O][\d;]*[A-Za-z~]|\e|./m)
|
|
82
|
+
return :quit if keys.any? { |k| ["q", "Q", "\e", "\x03"].include?(k) }
|
|
83
|
+
step = keys.sum { |k| ["\e[D", "\eOD", "h", "p", "b"].include?(k) ? -1 : 1 }
|
|
84
|
+
step.zero? ? nil : step
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/dopairb/version.rb
CHANGED