asterism-games 0.18.0 → 0.19.1

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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ module AsterismGames
7
+ module Commentate
8
+ # The page's web server (README "Commentary"): HTTP/1.1 by hand on the
9
+ # standard library's socket, listening on 127.0.0.1 only. It serves a
10
+ # few fixed paths (the page, its script and style, the two tile sheets)
11
+ # and /events, a stream of Server-Sent Events that pushes the table and
12
+ # the commentary to every open page. Nothing else: no files by name, no
13
+ # POST, and a Host header that is not this machine is refused (a page
14
+ # elsewhere cannot reach in through a name that points here). With a
15
+ # VOICEVOX voice (the hub's voice), also the fixed phrases'
16
+ # files (/clip/<digest>.wav, only those of the list) and the lines'
17
+ # sound (/voice/<digest>.wav, only the texts the hub registered:
18
+ # never a text a page sends).
19
+ class Server
20
+ ASSETS = File.expand_path("assets", __dir__)
21
+ FILES = {
22
+ "/" => ["index.html", "text/html; charset=utf-8"],
23
+ "/index.html" => ["index.html", "text/html; charset=utf-8"],
24
+ "/commentate.js" => ["commentate.js", "text/javascript; charset=utf-8"],
25
+ "/commentate.css" => ["commentate.css", "text/css; charset=utf-8"],
26
+ "/tiles_hand.bmp" => ["tiles_hand.bmp", "image/bmp"],
27
+ "/tiles_river.bmp" => ["tiles_river.bmp", "image/bmp"],
28
+ "/TILES_LICENSE.txt" => ["TILES_LICENSE.txt", "text/plain; charset=utf-8"]
29
+ }.freeze
30
+ CLIP = %r{\A/clip/([0-9a-f]{16}\.wav)\z}
31
+ LIVE = %r{\A/voice/([0-9a-f]{32}\.wav)\z}
32
+ LOCAL_HOSTS = %w[127.0.0.1 localhost].freeze
33
+ PING_S = 15
34
+ HEAD_LIMIT = 8192
35
+ READ_TIMEOUT_S = 10
36
+
37
+ attr_reader :port
38
+
39
+ # hub answers #hello(table) (the first events of a new page: an
40
+ # array of [event, data]) and #subscribe(client) / #unsubscribe(client).
41
+ def initialize(hub, port: 7470, tries: 20, host: "127.0.0.1", log: nil)
42
+ @hub = hub
43
+ @log = log
44
+ @server = nil
45
+ last = nil
46
+ tries.times do |i|
47
+ @server = TCPServer.new(host, port + i)
48
+ break
49
+ rescue Errno::EADDRINUSE, Errno::EACCES => e
50
+ last = e
51
+ raise if port.zero?
52
+ end
53
+ raise(last || Errno::EADDRINUSE) unless @server
54
+ @port = @server.addr[1]
55
+ @threads = []
56
+ end
57
+
58
+ def address = @server.addr[3]
59
+ def url = "http://127.0.0.1:#{@port}/"
60
+
61
+ def start
62
+ @accept = Thread.new do
63
+ loop do
64
+ sock = @server.accept
65
+ Thread.new(sock) { |s| serve(s) }
66
+ rescue IOError, Errno::EBADF
67
+ break
68
+ rescue StandardError => e
69
+ @log&.call("http: #{e.class}: #{e.message}")
70
+ end
71
+ end
72
+ self
73
+ end
74
+
75
+ def stop
76
+ @server.close
77
+ rescue IOError
78
+ nil
79
+ ensure
80
+ @accept&.kill
81
+ end
82
+
83
+ # ------------------------------------------------------------ one connection
84
+
85
+ def serve(sock)
86
+ req = read_request(sock)
87
+ return unless req
88
+ method, target, headers = req
89
+ host = headers["host"].to_s.sub(/:\d+\z/, "").delete("[]")
90
+ return respond(sock, 403, "text/plain", "not this machine\n") unless LOCAL_HOSTS.include?(host) || host == "::1"
91
+ return respond(sock, 405, "text/plain", "GET only\n", "Allow" => "GET, HEAD") unless %w[GET HEAD].include?(method)
92
+ path, query = target.split("?", 2)
93
+ if path == "/events"
94
+ events(sock, params(query))
95
+ elsif (m = path.match(CLIP) || path.match(LIVE))
96
+ voice = @hub.respond_to?(:voice) ? @hub.voice : nil
97
+ body = voice && (path.start_with?("/clip/") ? voice.clip(m[1]) : voice.wav(m[1]))
98
+ return respond(sock, 404, "text/plain", "not found\n") unless body
99
+ respond(sock, 200, "audio/wav", method == "HEAD" ? "" : body, "Cache-Control" => "max-age=600",
100
+ "Content-Length" => body.bytesize.to_s)
101
+ elsif (f = FILES[path])
102
+ body = File.binread(File.join(ASSETS, f[0]))
103
+ respond(sock, 200, f[1], method == "HEAD" ? "" : body, "Cache-Control" => "no-cache",
104
+ "Content-Length" => body.bytesize.to_s)
105
+ else
106
+ respond(sock, 404, "text/plain", "not found\n")
107
+ end
108
+ rescue IOError, SystemCallError
109
+ nil
110
+ ensure
111
+ begin
112
+ sock.close
113
+ rescue IOError
114
+ nil
115
+ end
116
+ end
117
+
118
+ # [method, target, {lowercase header => value}], or nil.
119
+ def read_request(sock)
120
+ head = +""
121
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + READ_TIMEOUT_S
122
+ until head.include?("\r\n\r\n") || head.include?("\n\n")
123
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ return nil if left <= 0 || head.bytesize > HEAD_LIMIT
125
+ return nil unless sock.wait_readable(left)
126
+ chunk = sock.readpartial(1024)
127
+ head << chunk
128
+ end
129
+ lines = head.split(/\r?\n/)
130
+ method, target, = lines.shift.to_s.split(" ", 3)
131
+ return nil unless method && target&.start_with?("/")
132
+ headers = {}
133
+ lines.each do |l|
134
+ break if l.empty?
135
+ k, v = l.split(":", 2)
136
+ headers[k.strip.downcase] = v.to_s.strip if v
137
+ end
138
+ [method, target, headers]
139
+ rescue EOFError
140
+ nil
141
+ end
142
+
143
+ def params(query)
144
+ query.to_s.split("&").to_h do |kv|
145
+ k, v = kv.split("=", 2)
146
+ [unescape(k), unescape(v)]
147
+ end
148
+ end
149
+
150
+ def unescape(s) = s.to_s.tr("+", " ").gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding("UTF-8")
151
+
152
+ STATUS = { 200 => "OK", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed" }.freeze
153
+
154
+ def respond(sock, code, type, body, extra = {})
155
+ h = { "Content-Type" => type, "Content-Length" => body.bytesize.to_s, "Connection" => "close",
156
+ "X-Content-Type-Options" => "nosniff" }.merge(extra)
157
+ sock.write("HTTP/1.1 #{code} #{STATUS[code]}\r\n#{h.map { |k, v| "#{k}: #{v}\r\n" }.join}\r\n")
158
+ sock.write(body) unless body.empty?
159
+ end
160
+
161
+ # The stream: the hub's first events, then whatever it pushes, and a
162
+ # comment line every PING_S so a dead page is noticed.
163
+ def events(sock, params)
164
+ sock.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n" \
165
+ "Cache-Control: no-cache\r\nConnection: keep-alive\r\nX-Content-Type-Options: nosniff\r\n\r\n")
166
+ client = Client.new(params["table"], hide: params["hide"] == "1")
167
+ @hub.subscribe(client)
168
+ @hub.hello(client).each { |ev, data| sock.write(Server.frame(ev, data)) }
169
+ loop do
170
+ msg = client.queue.pop(timeout: PING_S)
171
+ break if msg == :close
172
+ sock.write(msg.nil? ? ": ping\n\n" : msg)
173
+ end
174
+ ensure
175
+ @hub.unsubscribe(client) if client
176
+ end
177
+
178
+ # One Server-Sent Event: "event: name\ndata: json\n\n".
179
+ def self.frame(event, data)
180
+ "event: #{event}\ndata: #{JSON.generate(data)}\n\n"
181
+ end
182
+
183
+ # An open page: the table it watches (nil: the hub's choice), whether
184
+ # it hides the hands (H: the AI then gets none), and the frames
185
+ # waiting for it.
186
+ class Client
187
+ attr_accessor :table, :hide
188
+ attr_reader :queue
189
+
190
+ def initialize(table, hide: false)
191
+ @table = table.to_s.empty? ? nil : table
192
+ @hide = hide
193
+ @queue = Thread::Queue.new
194
+ end
195
+
196
+ def push(frame)
197
+ # A page that does not read (a tab asleep) is not waited for:
198
+ # past this many frames it is closed, and it opens again.
199
+ @queue.size > 2000 ? @queue.push(:close) : @queue.push(frame)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "monitor"
7
+ require_relative "voicevox"
8
+ require_relative "mahjong_phrases"
9
+
10
+ module AsterismGames
11
+ module Commentate
12
+ # The fixed phrases as WAV files (README "Commentary", "The voice"):
13
+ # the list is the gem's (mahjong_voice.yml), the sound is made on this
14
+ # machine by its VOICEVOX, into
15
+ # $XDG_CACHE_HOME/asterism-games/voices/<speaker>-<style>/ (by default
16
+ # ~/.cache/...). Nothing of it is redistributed; the directory can be
17
+ # deleted at any time and is made again. Each file is named by the
18
+ # digest of its phrasing, so a changed phrasing is made again and the
19
+ # files no phrasing names any more are removed.
20
+ class ClipBank
21
+ LIST = File.expand_path("mahjong_voice.yml", __dir__)
22
+ FILE = /\A[0-9a-f]{16}\.wav\z/
23
+
24
+ attr_reader :dir, :tone
25
+
26
+ def self.cache_root(env = ENV)
27
+ xdg = env["XDG_CACHE_HOME"].to_s
28
+ base = xdg.start_with?("/") ? xdg : File.join(Dir.home, ".cache")
29
+ File.join(base, "asterism-games", "voices")
30
+ end
31
+
32
+ # The directory of a character's style: <speaker>-<style>, with
33
+ # nothing in it that a file name cannot hold.
34
+ def self.dir_for(speaker, style, root: cache_root)
35
+ safe = ->(s) { s.to_s.gsub(%r{[/\\\x00-\x1f]}, "_").sub(/\A\.+/, "_") }
36
+ File.join(root, "#{safe.call(speaker)}-#{safe.call(style)}")
37
+ end
38
+
39
+ def self.load_list(path = LIST)
40
+ YAML.safe_load(File.read(path, encoding: "UTF-8")) || {}
41
+ end
42
+
43
+ # list: {key => {"word", tone => [phrasing]}}; client: a Voicevox
44
+ # (only for make!); id: the style's number.
45
+ def initialize(dir, tone: "plain", list: ClipBank.load_list, client: nil, id: nil, credit: nil)
46
+ @dir = dir
47
+ @tone = tone
48
+ @list = list
49
+ @client = client
50
+ @id = id
51
+ @credit = credit
52
+ @lock = Monitor.new
53
+ end
54
+
55
+ def keys = @list.keys
56
+
57
+ # The phrasings of a key in this tone (plain's when it has none).
58
+ def phrasings(key)
59
+ e = @list[key] || {}
60
+ Array(e[@tone] || e["plain"]).map { |t| MahjongPhrases.reading(t.to_s) }.reject(&:empty?)
61
+ end
62
+
63
+ # The word the line's speech leaves out after the key's phrase (as
64
+ # the speech reads it).
65
+ def word(key)
66
+ w = (@list[key] || {})["word"]
67
+ w && MahjongPhrases.reading(w)
68
+ end
69
+
70
+ def self.file_name(text) = "#{Digest::SHA256.hexdigest(text)[0, 16]}.wav"
71
+
72
+ # Every file the list names: {file => [key, phrasing]}.
73
+ def wanted
74
+ out = {}
75
+ @list.each_key { |k| phrasings(k).each { |t| out[ClipBank.file_name(t)] = [k, t] } }
76
+ out
77
+ end
78
+
79
+ def missing = wanted.keys.reject { |f| File.file?(File.join(@dir, f)) }
80
+
81
+ def ready?(key) = !ready_files(key).empty?
82
+
83
+ def ready_files(key)
84
+ phrasings(key).map { |t| ClipBank.file_name(t) }.select { |f| File.file?(File.join(@dir, f)) }
85
+ end
86
+
87
+ # One of the key's files at random (nil: none made yet).
88
+ def pick(key, rng = Random.new)
89
+ fs = ready_files(key)
90
+ fs.empty? ? nil : fs[rng.rand(fs.size)]
91
+ end
92
+
93
+ # The bytes of a file the list names (nil for anything else).
94
+ def read(file)
95
+ return nil unless file.to_s.match?(FILE) && wanted.key?(file)
96
+ path = File.join(@dir, file)
97
+ File.file?(path) ? File.binread(path) : nil
98
+ end
99
+
100
+ # Has the engine say every phrasing that has no file yet, and removes
101
+ # the files no phrasing names. Returns [made, already there].
102
+ # log: ->(message) for each file made.
103
+ def make!(log: nil)
104
+ raise ArgumentError, "make! needs the engine" unless @client && @id
105
+ @lock.synchronize do
106
+ FileUtils.mkdir_p(@dir)
107
+ want = wanted
108
+ (Dir.children(@dir).grep(FILE) - want.keys).each { |f| File.delete(File.join(@dir, f)) }
109
+ todo = want.reject { |f, _| File.file?(File.join(@dir, f)) }
110
+ todo.each_with_index do |(f, (key, text)), i|
111
+ wav = @client.synthesize(text, @id)
112
+ tmp = File.join(@dir, ".#{f}.#{Process.pid}.tmp")
113
+ File.binwrite(tmp, wav)
114
+ File.rename(tmp, File.join(@dir, f))
115
+ log&.call("#{i + 1}/#{todo.size} #{key}: #{text}")
116
+ end
117
+ write_credits
118
+ [todo.size, want.size - todo.size]
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def write_credits
125
+ return unless @credit
126
+ File.write(File.join(@dir, "CREDITS.txt"), <<~TEXT)
127
+ #{@credit}
128
+
129
+ Made on this machine by its VOICEVOX engine (asterism-games commentate voices --make)
130
+ from the phrases of asterism-games (mahjong_voice.yml). Not redistributed: anyone
131
+ who uses these files follows VOICEVOX's terms and the character's. Delete this
132
+ directory at any time; it is made again.
133
+ TEXT
134
+ end
135
+ end
136
+
137
+ # The lines said as they come (README "The voice"): each text the hub
138
+ # registers gets a digest (its temporary URL /voice/<digest>.wav), the
139
+ # engine synthesises them one by one in a thread of its own (the
140
+ # bigger lines first, the small old ones left for when they are
141
+ # asked), a page that asks for one waits until it is made. The same
142
+ # text is synthesised once while it is kept: at most max_entries WAVs
143
+ # and max_bytes, the least recently used dropped first.
144
+ class LiveVoice
145
+ ID = /\A[0-9a-f]{32}\z/
146
+ STALE_S = 30
147
+ WAIT_S = 30
148
+
149
+ attr_reader :synthesized
150
+
151
+ def initialize(client, id, max_entries: 200, max_bytes: 64 * 1024 * 1024, max_texts: 2000,
152
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, log: nil)
153
+ @client = client
154
+ @id = id
155
+ @max_entries = max_entries
156
+ @max_bytes = max_bytes
157
+ @max_texts = max_texts
158
+ @clock = clock
159
+ @log = log
160
+ @lock = Monitor.new
161
+ @cond = @lock.new_cond
162
+ @items = {} # digest => {text, prio, at, state (:new :queued :working :done :failed), wav}
163
+ @bytes = 0
164
+ @synthesized = 0
165
+ @worker = nil
166
+ @stop = false
167
+ end
168
+
169
+ def digest(text) = Digest::SHA256.hexdigest("#{@id}\n#{text}")[0, 32]
170
+
171
+ # A text to say: its digest. queue: synthesise it now (a line as it
172
+ # comes) or only when asked (the same line with the hands hidden).
173
+ def register(text, prio: 0, queue: true)
174
+ d = digest(text)
175
+ @lock.synchronize do
176
+ it = (@items[d] ||= { "text" => text, "state" => :new, "prio" => prio })
177
+ touch(d)
178
+ it["at"] = @clock.call
179
+ it["prio"] = [it["prio"].to_i, prio].max
180
+ if queue && %i[new failed].include?(it["state"])
181
+ it["state"] = :queued
182
+ start_worker
183
+ @cond.broadcast
184
+ end
185
+ forget_texts
186
+ end
187
+ d
188
+ end
189
+
190
+ def known?(d) = @lock.synchronize { @items.key?(d) }
191
+
192
+ # The WAV of a digest, synthesised now if it was not (waits up to
193
+ # wait_s). nil: unknown, or the engine failed.
194
+ def fetch(d, wait_s: WAIT_S)
195
+ return nil unless d.to_s.match?(ID)
196
+ deadline = @clock.call + wait_s
197
+ @lock.synchronize do
198
+ it = @items[d] or return nil
199
+ if %i[new failed].include?(it["state"])
200
+ it["state"] = :queued
201
+ it["prio"] = 9 # asked for: next
202
+ it["at"] = @clock.call
203
+ start_worker
204
+ @cond.broadcast
205
+ end
206
+ until %i[done failed].include?(it["state"])
207
+ left = deadline - @clock.call
208
+ return nil if left <= 0
209
+ @cond.wait(left)
210
+ end
211
+ return nil unless it["state"] == :done
212
+ touch(d)
213
+ it["wav"]
214
+ end
215
+ end
216
+
217
+ def stop
218
+ @lock.synchronize do
219
+ @stop = true
220
+ @cond.broadcast
221
+ end
222
+ @worker&.join(2)
223
+ end
224
+
225
+ private
226
+
227
+ def start_worker
228
+ return if @worker&.alive?
229
+ @worker = Thread.new { work }
230
+ end
231
+
232
+ def work
233
+ loop do
234
+ d = it = nil
235
+ @lock.synchronize do
236
+ loop do
237
+ return if @stop
238
+ now = @clock.call
239
+ # A small line nobody asked for, left too long: not now.
240
+ @items.each_value do |x|
241
+ x["state"] = :new if x["state"] == :queued && x["prio"] < 2 && now - x["at"] > STALE_S
242
+ end
243
+ d, it = @items.select { |_, x| x["state"] == :queued }.max_by { |_, x| [x["prio"], -x["at"]] }
244
+ break if d
245
+ @cond.wait(5)
246
+ end
247
+ it["state"] = :working
248
+ end
249
+ wav = nil
250
+ begin
251
+ wav = @client.synthesize(it["text"], @id)
252
+ rescue StandardError => e
253
+ @log&.call("voice: #{e.message}")
254
+ end
255
+ @lock.synchronize do
256
+ if wav
257
+ it["wav"] = wav
258
+ it["state"] = :done
259
+ @bytes += wav.bytesize
260
+ @synthesized += 1
261
+ touch(d) # the newest: never the one evicted now
262
+ evict
263
+ else
264
+ it["state"] = :failed
265
+ end
266
+ @cond.broadcast
267
+ end
268
+ end
269
+ end
270
+
271
+ def touch(d)
272
+ it = @items.delete(d)
273
+ @items[d] = it if it
274
+ end
275
+
276
+ # The least recently used WAVs out, past the limits (their texts stay:
277
+ # asked again, they are made again).
278
+ def evict
279
+ done = @items.select { |_, x| x["state"] == :done }
280
+ while done.size > @max_entries || @bytes > @max_bytes
281
+ d, x = done.first
282
+ break unless d
283
+ @bytes -= x["wav"].bytesize
284
+ x["wav"] = nil
285
+ x["state"] = :new
286
+ done.delete(d)
287
+ end
288
+ end
289
+
290
+ def forget_texts
291
+ while @items.size > @max_texts
292
+ d, x = @items.find { |_, i| %i[new failed].include?(i["state"]) }
293
+ break unless d
294
+ @items.delete(d)
295
+ @bytes -= x["wav"].to_s.bytesize
296
+ end
297
+ end
298
+ end
299
+
300
+ # The voice of the commentary: VOICEVOX's character (the fixed phrases
301
+ # and the live lines) when the engine is there, else the browser's own
302
+ # voice (status says which, the page shows the credit).
303
+ class Voice
304
+ attr_reader :client, :speaker, :style, :id, :bank, :live, :tone, :engine_version
305
+
306
+ # Looks for the engine (GET /version) and the style's number. nil
307
+ # when the engine is not there (the browser's voice reads);
308
+ # Voicevox::NotFound when the character or the style is not.
309
+ def self.connect(url:, speaker:, style:, root: ClipBank.cache_root, log: nil, list: ClipBank.load_list)
310
+ client = Voicevox.new(url)
311
+ begin
312
+ version = client.version
313
+ rescue Voicevox::Error
314
+ return nil
315
+ end
316
+ id = client.style_id(speaker, style)
317
+ new(client, speaker, style, id, version: version, root: root, log: log, list: list)
318
+ end
319
+
320
+ def initialize(client, speaker, style, id, version: nil, root: ClipBank.cache_root, log: nil, list: ClipBank.load_list)
321
+ @client = client
322
+ @speaker = speaker
323
+ @style = style
324
+ @id = id
325
+ @engine_version = version
326
+ @log = log
327
+ @tone = MahjongPhrases.tone_for(speaker)
328
+ @bank = ClipBank.new(ClipBank.dir_for(speaker, style, root: root), tone: @tone, list: list, client: client,
329
+ id: id, credit: credit)
330
+ @live = LiveVoice.new(client, id, log: log)
331
+ end
332
+
333
+ def credit = "VOICEVOX:#{@speaker}"
334
+
335
+ # What the page is told.
336
+ def status
337
+ { "engine" => "voicevox", "speaker" => @speaker, "style" => @style, "credit" => credit,
338
+ "start" => (f = @bank.pick("start")) ? "/clip/#{f}" : nil }
339
+ end
340
+
341
+ # Makes the missing fixed phrases in a thread (the commentary starts
342
+ # meanwhile; a phrase not made yet is not played).
343
+ def make_missing_async
344
+ n = @bank.missing.size
345
+ return nil if n.zero?
346
+ @log&.call("voice: making #{n} fixed phrase(s) in #{@bank.dir}")
347
+ Thread.new do
348
+ made, = @bank.make!
349
+ @log&.call("voice: #{made} fixed phrase(s) made")
350
+ rescue StandardError => e
351
+ @log&.call("voice: the fixed phrases: #{e.message}")
352
+ end
353
+ end
354
+
355
+ # The voice of a line: the fixed phrases to play first (their URLs),
356
+ # then the rest of the line synthesised (its URL; the speech without
357
+ # the words the phrases said). "plive": the same with the hands hidden.
358
+ def plan(line, rng = Random.new)
359
+ files = Array(line["clips"]).map { |k| [k, @bank.pick(k, rng)] }.select { |_, f| f }
360
+ words = files.map { |k, _| @bank.word(k) }
361
+ out = { "clips" => files.map { |_, f| "/clip/#{f}" } }
362
+ s = Voice.after(line["speech"], words)
363
+ out["live"] = s.empty? ? nil : "/voice/#{@live.register(s, prio: line['prio'].to_i)}.wav"
364
+ if line.key?("pspeech")
365
+ ps = line["pspeech"] && Voice.after(line["pspeech"], words)
366
+ out["plive"] = if ps.nil? || ps.empty? then nil
367
+ elsif ps == s then out["live"]
368
+ else "/voice/#{@live.register(ps, prio: line['prio'].to_i, queue: false)}.wav"
369
+ end
370
+ end
371
+ out
372
+ end
373
+
374
+ # The speech without the words the fixed phrases just said, when it
375
+ # begins with them (ロン CPU1、… after ロン! is CPU1、…).
376
+ def self.after(speech, words)
377
+ s = speech.to_s.strip
378
+ words.compact.each do |w|
379
+ s = s.sub(/\A#{Regexp.escape(w)}(なのだ|です)?[!!、。,.\s]*/, "")
380
+ end
381
+ s.strip
382
+ end
383
+
384
+ def clip(file) = @bank.read(file)
385
+
386
+ def wav(file)
387
+ d = file.to_s.delete_suffix(".wav")
388
+ @live.fetch(d)
389
+ end
390
+
391
+ def stop = @live.stop
392
+ end
393
+ end
394
+ end