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.
@@ -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
- @game = Game.new
12
- @director = Director.new(config)
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
- @director.intro if active?
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
- @director.outro(@game.stats) if active? && @game.evals > 0
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] ||= candidates.find { |c| c.ascii_only? || (utf8? && c.each_char.all? { |ch| char_width(ch) == 1 }) } || candidates.last
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dopairb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.0"
5
5
  end