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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +321 -3
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- metadata +18 -2
|
@@ -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
|