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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module AsterismGames
8
+ module Commentate
9
+ # The VOICEVOX engine's HTTP (README "Commentary", "The voice"): a free
10
+ # Japanese speech synthesiser running on this machine (the VOICEVOX app
11
+ # or its engine in docker). The standard library's net/http only.
12
+ #
13
+ # vv = Voicevox.new("http://127.0.0.1:50021")
14
+ # vv.version # => "0.25.0" (raises Voicevox::Error when it is not there)
15
+ # id = vv.style_id("ずんだもん", "ノーマル") # the number from /speakers, never assumed
16
+ # wav = vv.synthesize("リーチなのだ!", id) # the WAV's bytes
17
+ class Voicevox
18
+ DEFAULT_URL = "http://127.0.0.1:50021"
19
+ # Where people get the VOICEVOX app (it is not in this gem).
20
+ HOME = "https://voicevox.hiroshiba.jp/"
21
+
22
+ # The engine is not there, or said no.
23
+ class Error < StandardError; end
24
+
25
+ # No such character or style: the message lists what there is.
26
+ class NotFound < Error
27
+ attr_reader :candidates
28
+
29
+ def initialize(message, candidates)
30
+ super(message)
31
+ @candidates = candidates
32
+ end
33
+ end
34
+
35
+ attr_reader :url
36
+
37
+ def initialize(url = DEFAULT_URL, open_timeout: 2, read_timeout: 60)
38
+ @url = url.to_s.empty? ? DEFAULT_URL : url.to_s.chomp("/")
39
+ @uri = URI(@url)
40
+ raise Error, "#{@url}: not an http:// URL" unless @uri.is_a?(URI::HTTP) && @uri.host
41
+ @open_timeout = open_timeout
42
+ @read_timeout = read_timeout
43
+ end
44
+
45
+ # GET /version: the engine's version (a JSON string).
46
+ def version
47
+ v = JSON.parse(request(:get, "/version").force_encoding("UTF-8"))
48
+ v.is_a?(String) ? v : v.to_s
49
+ rescue JSON::ParserError => e
50
+ raise Error, "#{@url}/version: #{e.message}"
51
+ end
52
+
53
+ # GET /speakers: [{"name", "styles" => [{"name", "id"}]}], as the engine
54
+ # says them.
55
+ def speakers
56
+ list = JSON.parse(request(:get, "/speakers").force_encoding("UTF-8"))
57
+ raise Error, "#{@url}/speakers: not a list" unless list.is_a?(Array)
58
+ list.map do |s|
59
+ { "name" => s["name"].to_s,
60
+ "styles" => Array(s["styles"]).map { |st| { "name" => st["name"].to_s, "id" => st["id"] } } }
61
+ end
62
+ rescue JSON::ParserError => e
63
+ raise Error, "#{@url}/speakers: #{e.message}"
64
+ end
65
+
66
+ # The style's number, looked up by the character's and the style's
67
+ # names. NotFound lists the characters (or the character's styles).
68
+ def style_id(speaker, style, list = speakers)
69
+ s = list.find { |x| x["name"] == speaker }
70
+ unless s
71
+ raise NotFound.new("VOICEVOX has no character #{speaker.inspect} (there are: #{list.map { |x| x['name'] }.join(', ')})",
72
+ list.map { |x| x["name"] })
73
+ end
74
+ st = s["styles"].find { |x| x["name"] == style }
75
+ unless st
76
+ names = s["styles"].map { |x| x["name"] }
77
+ raise NotFound.new("#{speaker} has no style #{style.inspect} (there are: #{names.join(', ')})", names)
78
+ end
79
+ st["id"]
80
+ end
81
+
82
+ # POST /audio_query then POST /synthesis: the WAV of text in style id.
83
+ def synthesize(text, id)
84
+ q = request(:post, "/audio_query?#{URI.encode_www_form(text: text, speaker: id)}", "")
85
+ wav = request(:post, "/synthesis?#{URI.encode_www_form(speaker: id)}", q, "Content-Type" => "application/json")
86
+ raise Error, "#{@url}/synthesis: not a WAV" unless wav.start_with?("RIFF")
87
+ wav
88
+ end
89
+
90
+ private
91
+
92
+ def request(method, path, body = nil, headers = {})
93
+ http = Net::HTTP.new(@uri.host, @uri.port)
94
+ http.use_ssl = @uri.scheme == "https"
95
+ http.open_timeout = @open_timeout
96
+ http.read_timeout = @read_timeout
97
+ http.write_timeout = @read_timeout if http.respond_to?(:write_timeout=)
98
+ res = http.start do |h|
99
+ method == :get ? h.get(path) : h.post(path, body.to_s, headers)
100
+ end
101
+ raise Error, "#{@url}#{path[/\A[^?]*/]}: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
102
+ res.body.to_s.b
103
+ rescue Error
104
+ raise
105
+ rescue StandardError => e
106
+ raise Error, "#{@url}: #{e.class}: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "play"
5
+ require_relative "commentate/voicevox"
6
+ require_relative "commentate/voice"
7
+ require_relative "commentate/ai_comment"
8
+
9
+ module AsterismGames
10
+ # `asterism-games commentate <game>` (README "Commentary"): watches a
11
+ # table from outside (its spectate key, README "Spectate"), draws it in
12
+ # a browser and comments on it in captions and a voice (VOICEVOX's, on
13
+ # this machine, else the browser's). It
14
+ # only reads: it never joins or calls a table. CRuby and the asterism gem
15
+ # only; the page is served from this process on 127.0.0.1.
16
+ module Commentate
17
+ GAMES = {
18
+ "mahjong" => "a mahjong table: the four hands drawn with Family mruby's tiles, the commentary as captions and voice"
19
+ }.freeze
20
+ DEFAULT_PORT = 7470
21
+ DEFAULT_SPEAKER = "ずんだもん"
22
+ DEFAULT_STYLE = "ノーマル"
23
+
24
+ USAGE = <<~TEXT
25
+ Usage: asterism-games commentate <game> [options]
26
+
27
+ Games:
28
+ %<games>s
29
+
30
+ Options:
31
+ --router LOCATOR the Zenoh router (tcp/127.0.0.1:7447 when nothing says otherwise)
32
+ --credentials FILE a console's .tar (a viewer's or a spectator's role reads the
33
+ spectate keys; the router and the node ID come from it)
34
+ --namespace PREFIX a room's tables (room/01: their spectate keys room01-<table>,
35
+ outside the room; the connection itself has no namespace)
36
+ --table TABLE the table to show first (<table>, <host>/<table>, or
37
+ <host>/spectate/<table>); without it every table that puts
38
+ its spectate key is listed and the first one shown
39
+ --node NODE the node ID (default: commentate-<host>-<pid>, or the .tar's name)
40
+ --port N the page's port on 127.0.0.1 (default %<port>d; taken: the next free)
41
+ --public hide the hands: the page never gets them and the commentary
42
+ never tells them (for a stream; H on the page does the same
43
+ for that page only)
44
+ --no-open do not open the browser
45
+ --json every commentary line as a JSON line on standard output
46
+ --voicevox URL the VOICEVOX engine (default VOICEVOX_URL, else
47
+ http://127.0.0.1:50021); not there: the browser's voice.
48
+ VOICEVOX is a free app you install and start yourself
49
+ (not in this gem)
50
+ --speaker NAME the VOICEVOX character (default %<speaker>s)
51
+ --style NAME its style (default %<style>s)
52
+ --ai on|off the AI's one-liners (default on; they need
53
+ ANTHROPIC_API_KEY and the anthropic gem)
54
+ --ai-model MODEL the model (default %<ai_model>s)
55
+ --ai-daily-usd USD what they may cost a day (default %<ai_usd>.2f); then the
56
+ fixed commentary alone
57
+ -h, --help this text
58
+
59
+ The page: Space pauses, M turns the voice off and on, H hides the hands.
60
+ The browser speaks once the page has been clicked (its autoplay rule).
61
+
62
+ The voice: VOICEVOX's character says the fixed phrases (リーチ, ポン,
63
+ ロン, the rounds ...) from WAV files made on this machine at the first
64
+ start, and the rest of each line as it comes. The page shows the credit
65
+ (VOICEVOX:<character>); put it in a stream's description too.
66
+
67
+ The AI's one-liners: at the big moments (a riichi, a win, a draw, the
68
+ lead changing hands, the last round, the end) and after a quiet
69
+ minute, Claude says a short line after the fixed commentary (at most
70
+ one call every 20 s, 8 s for a bigger scene than the last, such as a
71
+ win after a riichi; only while a page is open). With the hands hidden
72
+ (--public, H) it is not told the hands. What it costs is counted in
73
+ $XDG_STATE_HOME/asterism-games/commentary_ai_usage.json
74
+ (~/.local/state/... by default); the page shows today's money.
75
+
76
+ Usage: asterism-games commentate voices --list [--voicevox URL]
77
+ asterism-games commentate voices --make [--voicevox URL] [--speaker NAME] [--style NAME]
78
+ --list the engine's characters and their styles
79
+ --make make the fixed phrases' WAV files now (the commentary makes
80
+ the missing ones at its start too), in
81
+ $XDG_CACHE_HOME/asterism-games/voices/<speaker>-<style>/
82
+ (~/.cache/... by default; delete it at any time)
83
+
84
+ With neither --router nor --credentials, ASTERISM_ROUTER and
85
+ ASTERISM_CREDENTIALS from the environment are used (all or nothing).
86
+ TEXT
87
+
88
+ module_function
89
+
90
+ def usage
91
+ format(USAGE, games: GAMES.map { |n, text| format(" %-9s %s", n, text) }.join("\n"), port: DEFAULT_PORT,
92
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE, ai_model: AIComment::DEFAULT_MODEL,
93
+ ai_usd: AIComment::DEFAULT_DAILY_USD)
94
+ end
95
+
96
+ def main(argv, out: $stdout, err: $stderr)
97
+ game = argv.first
98
+ if game.nil? || %w[-h --help help].include?(game)
99
+ out.puts usage
100
+ return 0
101
+ end
102
+ return voices(argv.drop(1), out: out, err: err) if game == "voices"
103
+ unless GAMES.key?(game)
104
+ err.puts "asterism-games commentate: no game #{game.inspect} (#{GAMES.keys.join(', ')})"
105
+ return 2
106
+ end
107
+ opts = parse(argv.drop(1))
108
+ if opts[:help]
109
+ out.puts usage
110
+ return 0
111
+ end
112
+ require_relative "commentate/mahjong"
113
+ MahjongRunner.run(opts, out: out, err: err)
114
+ 0
115
+ rescue Voicevox::NotFound => e
116
+ err.puts "asterism-games commentate #{game}: #{e.message} (voices --list)"
117
+ 2
118
+ rescue ArgumentError => e
119
+ err.puts "asterism-games commentate #{game}: #{e.message} (--help)"
120
+ 2
121
+ end
122
+
123
+ def parse(args, env: ENV)
124
+ opts = { router: nil, credentials: nil, namespace: nil, table: nil, node: nil, port: DEFAULT_PORT,
125
+ public: false, open: true, json: false, help: false,
126
+ voicevox: env["VOICEVOX_URL"].to_s.empty? ? Voicevox::DEFAULT_URL : env["VOICEVOX_URL"],
127
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE,
128
+ ai: true, ai_model: AIComment::DEFAULT_MODEL, ai_daily_usd: AIComment::DEFAULT_DAILY_USD }
129
+ args = args.dup
130
+ until args.empty?
131
+ k = args.shift
132
+ need = lambda do
133
+ v = args.shift
134
+ raise ArgumentError, "#{k} takes a value" if v.nil? || v.start_with?("--")
135
+ v
136
+ end
137
+ case k
138
+ when "--ai"
139
+ v = need.call
140
+ raise ArgumentError, "--ai takes on or off" unless %w[on off].include?(v)
141
+ opts[:ai] = v == "on"
142
+ when "--ai-model" then opts[:ai_model] = need.call
143
+ when "--ai-daily-usd"
144
+ v = Float(need.call, exception: false)
145
+ raise ArgumentError, "--ai-daily-usd takes a number of dollars (0 or more)" unless v&.finite? && v >= 0
146
+ opts[:ai_daily_usd] = v
147
+ when "--router" then opts[:router] = need.call
148
+ when "--credentials" then opts[:credentials] = need.call
149
+ when "--namespace" then opts[:namespace] = need.call
150
+ when "--table" then opts[:table] = need.call
151
+ when "--node" then opts[:node] = need.call
152
+ when "--port"
153
+ v = need.call
154
+ raise ArgumentError, "--port takes a number (1-65535)" unless v.match?(/\A\d+\z/) && v.to_i.between?(1, 65_535)
155
+ opts[:port] = v.to_i
156
+ when "--public" then opts[:public] = true
157
+ when "--no-open" then opts[:open] = false
158
+ when "--json" then opts[:json] = true
159
+ when "--voicevox" then opts[:voicevox] = need.call
160
+ when "--speaker" then opts[:speaker] = need.call
161
+ when "--style" then opts[:style] = need.call
162
+ when "-h", "--help" then opts[:help] = true
163
+ else raise ArgumentError, "unknown option #{k}"
164
+ end
165
+ end
166
+ Play.network!(opts, env)
167
+ opts
168
+ end
169
+
170
+ # `commentate voices --list | --make`: the engine's characters, or the
171
+ # fixed phrases' files made now.
172
+ def voices(args, out: $stdout, err: $stderr, env: ENV, root: nil)
173
+ opts = { voicevox: env["VOICEVOX_URL"].to_s.empty? ? Voicevox::DEFAULT_URL : env["VOICEVOX_URL"],
174
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE, list: false, make: false }
175
+ args = args.dup
176
+ until args.empty?
177
+ k = args.shift
178
+ need = lambda do
179
+ v = args.shift
180
+ raise ArgumentError, "#{k} takes a value" if v.nil? || v.start_with?("--")
181
+ v
182
+ end
183
+ case k
184
+ when "--list" then opts[:list] = true
185
+ when "--make" then opts[:make] = true
186
+ when "--voicevox" then opts[:voicevox] = need.call
187
+ when "--speaker" then opts[:speaker] = need.call
188
+ when "--style" then opts[:style] = need.call
189
+ when "-h", "--help" then return out.puts(usage) || 0
190
+ else raise ArgumentError, "unknown option #{k}"
191
+ end
192
+ end
193
+ raise ArgumentError, "--list or --make" unless opts[:list] || opts[:make]
194
+ vv = Voicevox.new(opts[:voicevox])
195
+ begin
196
+ ver = vv.version
197
+ rescue Voicevox::Error => e
198
+ err.puts "asterism-games commentate voices: no VOICEVOX at #{vv.url} (#{e.message}); " \
199
+ "start the VOICEVOX app or its engine (README \"Commentary\", \"The voice\")"
200
+ return 1
201
+ end
202
+ list = vv.speakers
203
+ if opts[:list]
204
+ out.puts "VOICEVOX #{ver} at #{vv.url}: #{list.size} character(s) (--speaker NAME --style NAME)"
205
+ list.each { |s| out.puts " #{s['name']}: #{s['styles'].map { |st| "#{st['name']} (#{st['id']})" }.join(', ')}" }
206
+ end
207
+ if opts[:make]
208
+ id = vv.style_id(opts[:speaker], opts[:style], list)
209
+ voice = Voice.new(vv, opts[:speaker], opts[:style], id, version: ver, root: root || ClipBank.cache_root)
210
+ out.puts "voices: #{opts[:speaker]} (#{opts[:style]}, #{id}) into #{voice.bank.dir}"
211
+ made, had = voice.bank.make!(log: ->(m) { out.puts " #{m}" })
212
+ out.puts "voices: #{made} made, #{had} already there. Credit: #{voice.credit}"
213
+ end
214
+ 0
215
+ rescue Voicevox::Error => e
216
+ err.puts "asterism-games commentate voices: #{e.message}"
217
+ 1
218
+ rescue ArgumentError => e
219
+ err.puts "asterism-games commentate voices: #{e.message} (--help)"
220
+ 2
221
+ end
222
+ end
223
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism-games
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
@@ -35,7 +35,8 @@ description: 'A framework for the server side of a game on an Asterism network:
35
35
  time), the rooms of an Asterism console and the permanent tables next to them. A
36
36
  game subclasses AsterismGames::Game and AsterismGames::Remote and writes only its
37
37
  rules and the methods it exposes. Comes with othello and a shared drawing board
38
- as samples, and terminal clients for othello and mahjong (asterism-games play).'
38
+ as samples, terminal clients for othello and mahjong (asterism-games play), and
39
+ a mahjong commentator that draws a table in the browser (asterism-games commentate).'
39
40
  email:
40
41
  executables:
41
42
  - asterism-games
@@ -53,6 +54,21 @@ files:
53
54
  - lib/asterism_games/board.rb
54
55
  - lib/asterism_games/board/remote.rb
55
56
  - lib/asterism_games/board/table.rb
57
+ - lib/asterism_games/commentate.rb
58
+ - lib/asterism_games/commentate/ai_comment.rb
59
+ - lib/asterism_games/commentate/assets/TILES_LICENSE.txt
60
+ - lib/asterism_games/commentate/assets/commentate.css
61
+ - lib/asterism_games/commentate/assets/commentate.js
62
+ - lib/asterism_games/commentate/assets/index.html
63
+ - lib/asterism_games/commentate/assets/tiles_hand.bmp
64
+ - lib/asterism_games/commentate/assets/tiles_river.bmp
65
+ - lib/asterism_games/commentate/mahjong.rb
66
+ - lib/asterism_games/commentate/mahjong_events.rb
67
+ - lib/asterism_games/commentate/mahjong_phrases.rb
68
+ - lib/asterism_games/commentate/mahjong_voice.yml
69
+ - lib/asterism_games/commentate/server.rb
70
+ - lib/asterism_games/commentate/voice.rb
71
+ - lib/asterism_games/commentate/voicevox.rb
56
72
  - lib/asterism_games/config.rb
57
73
  - lib/asterism_games/game.rb
58
74
  - lib/asterism_games/host.rb