asterism-games 0.20.0 → 0.21.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/CHANGELOG.md +29 -0
- data/README.md +65 -7
- data/VERSION +1 -1
- data/lib/asterism_games/commentate/assets/SOUNDS_LICENSE.txt +63 -0
- data/lib/asterism_games/commentate/assets/commentate.css +9 -4
- data/lib/asterism_games/commentate/assets/commentate.js +298 -7
- data/lib/asterism_games/commentate/assets/index.html +5 -3
- data/lib/asterism_games/commentate/assets/sounds/after_the_rain.mp3 +0 -0
- data/lib/asterism_games/commentate/assets/sounds/chi.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/discard.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/discard_other.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/draw.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/encounter.mp3 +0 -0
- data/lib/asterism_games/commentate/assets/sounds/fanfare1.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/fanfare2.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/fanfare3.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/fanfare4.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/kan.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/kyuushu.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/points.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/pon.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/riichi.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/ron.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/round.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/ryuukyoku.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/sounds.json +335 -0
- data/lib/asterism_games/commentate/assets/sounds/spring_thing.mp3 +0 -0
- data/lib/asterism_games/commentate/assets/sounds/tsumo.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/turn.wav +0 -0
- data/lib/asterism_games/commentate/assets/sounds/yaku.wav +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +11 -4
- data/lib/asterism_games/commentate/mahjong_sounds.rb +118 -0
- data/lib/asterism_games/commentate/server.rb +14 -2
- data/lib/asterism_games/commentate.rb +8 -2
- data/lib/asterism_games/play/mahjong.rb +3 -1
- metadata +27 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2b83742bc350a97c81593529368c69fd2143887ac42293d62c9459c28c47b91
|
|
4
|
+
data.tar.gz: d608f964a2f5356ebe84485ed7042558f66c150e530b34aa787a113a431f64fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d50462655686680b282f50e3e68ceccbc0f761c9b9515013877f4e06e8c6828233ff5795c14c9c008e7bb5fa5b663ff39aea08d2dc96741616fee5f5bc6ca7e6
|
|
7
|
+
data.tar.gz: fbb3bbeee07f77a11c01e7d685b8a7f0a239b0ef17f8f8782b0f8e4adbf466fade43708eb6b5e1daa1389c8de93abccf5f39e2afee5e2a20c338ab411ce14aaa
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
3
3
|
Versions before 0.11.1 are noted in the README where each feature is
|
|
4
4
|
described ("games 0.10.0" and the like).
|
|
5
5
|
|
|
6
|
+
## 0.21.0
|
|
7
|
+
|
|
8
|
+
- **The commentary's sound** (README "The sound"): the page of
|
|
9
|
+
`commentate mahjong` plays Family mruby's mahjong app's BGM and effects,
|
|
10
|
+
the same sounds at the same moments as the app. The BGM follows the
|
|
11
|
+
app's scenes (the lobby, the game, the tense part, none at the end),
|
|
12
|
+
starts low (the app's levels: off, low, mid, high, **B** or the bar's
|
|
13
|
+
button) and goes down to half while the voice speaks. The effects (a
|
|
14
|
+
round's start, the jingles of the calls, riichi and the wins, the
|
|
15
|
+
winner's points, the draws, a discard, the fanfare at the end; as for
|
|
16
|
+
the app's spectator, no draw or turn sound) play beside the voice, sharing the APU's channels as in the
|
|
17
|
+
app; **E** turns them off and on; the page remembers both. Which sound
|
|
18
|
+
when is a pure function of two views (`commentate/mahjong_sounds.rb`);
|
|
19
|
+
each `state` event now carries `bgm` and `sounds`.
|
|
20
|
+
- The files are in the gem (`commentate/assets/sounds/`, 3.6 MB: WAVs for
|
|
21
|
+
the effects, MP3s for the three tunes, `sounds.json`), made with Family
|
|
22
|
+
mruby's own APU emulator by fmruby-core's `tools/mahjong_sounds.rb`.
|
|
23
|
+
The effects are MIT here; the BGM is shiru8bit's under CC BY 3.0,
|
|
24
|
+
unchanged (`SOUNDS_LICENSE.txt`), credited in the page's corner. The
|
|
25
|
+
web server serves `/sounds/` (those files only) and
|
|
26
|
+
`/SOUNDS_LICENSE.txt`.
|
|
27
|
+
|
|
28
|
+
## 0.20.1
|
|
29
|
+
|
|
30
|
+
- `play mahjong`: an owner's command queued with `--commands` (`watch`,
|
|
31
|
+
`start`, `cpu` ...) while someone else holds the owner's role now says
|
|
32
|
+
so (「卓の主ではありません。卓の主の役が回ってくるのを待っています」)
|
|
33
|
+
instead of asking for a seat: since 0.20.0 an owner may be unseated.
|
|
34
|
+
|
|
6
35
|
## 0.20.0
|
|
7
36
|
|
|
8
37
|
- **Watch games** (README "Watch games"): the owner of a mahjong table
|
data/README.md
CHANGED
|
@@ -92,7 +92,7 @@ nothing else (othello, the drawing board), `bin/asterism-games` (with
|
|
|
92
92
|
`play`, the terminal clients of othello and mahjong: a mahjong table needs
|
|
93
93
|
riichi_engine, its client does not; and `commentate`, a mahjong table watched
|
|
94
94
|
in the browser with commentary, with the tile sheets of Family mruby's
|
|
95
|
-
mahjong app). It depends on
|
|
95
|
+
mahjong app and its sound: the effects and the BGM, "The sound"). It depends on
|
|
96
96
|
the asterism gem only (0.7.1 or later in
|
|
97
97
|
0.x; what came after 0.7.1, such as `@status` and `on_app_join`, is used
|
|
98
98
|
when there). Not in it: mahjong (`app/`, it needs the riichi_engine fork
|
|
@@ -1568,7 +1568,8 @@ when it is taken), prints the address and opens it in the browser (macOS
|
|
|
1568
1568
|
each, the newest at the bottom. Two seats to a row in a wide window, one
|
|
1569
1569
|
in a narrow one (a phone). Keys: **Space** pauses (what comes meanwhile
|
|
1570
1570
|
is held and shown when it goes on), **M** turns the voice off and on,
|
|
1571
|
-
**
|
|
1571
|
+
**B** steps the BGM (off, low, mid, high), **E** turns the effects off
|
|
1572
|
+
and on, **H** hides the hands on that page (backs instead of the tiles, and the
|
|
1572
1573
|
lines that tell a hand left out). A table that several hosts put is
|
|
1573
1574
|
chosen in the bar.
|
|
1574
1575
|
- **The AI's one-liners.** Claude's short lines at the big moments,
|
|
@@ -1583,6 +1584,9 @@ when it is taken), prints the address and opens it in the browser (macOS
|
|
|
1583
1584
|
Japanese voice when it has one (the bar says which voice, or that it has
|
|
1584
1585
|
none). Browsers play sound only after the page has been clicked once:
|
|
1585
1586
|
the page says so until then. "The voice" below.
|
|
1587
|
+
- **The sound.** The BGM and the effects of Family mruby's mahjong app,
|
|
1588
|
+
at the moments the app plays them, the BGM low by default and lower
|
|
1589
|
+
while the voice speaks. "The sound" below.
|
|
1586
1590
|
- **The commentary.** Made by rules from the difference between one view
|
|
1587
1591
|
and the one before (pure functions, `commentate/mahjong_events.rb` and
|
|
1588
1592
|
`mahjong_phrases.rb`, so the host can take them over later), each event
|
|
@@ -1601,9 +1605,10 @@ when it is taken), prints the address and opens it in the browser (macOS
|
|
|
1601
1605
|
game goes on as before. The delay of the table is the host's
|
|
1602
1606
|
("Spectators"); the page shows it and adds none.
|
|
1603
1607
|
- **The web server.** HTTP by hand on the standard library's `socket`
|
|
1604
|
-
(no gem): the page, its script and style, the two tile sheets
|
|
1605
|
-
`/
|
|
1606
|
-
|
|
1608
|
+
(no gem): the page, its script and style, the two tile sheets, the
|
|
1609
|
+
sounds (`/sounds/`, the files of `assets/sounds/` only) and `/events`,
|
|
1610
|
+
a stream of Server-Sent Events (`status`, `state`, `line`) that pushes
|
|
1611
|
+
the table and the commentary. It listens on `127.0.0.1`
|
|
1607
1612
|
only, answers GET (and HEAD) on those paths only, and refuses a request
|
|
1608
1613
|
whose Host is not `127.0.0.1` or `localhost`. The page loads nothing from
|
|
1609
1614
|
elsewhere.
|
|
@@ -1714,8 +1719,61 @@ your character before you publish anything; they can change):
|
|
|
1714
1719
|
description too** (the page shows it in its corner).
|
|
1715
1720
|
- Whoever you give generated voices to must follow the same terms, so
|
|
1716
1721
|
they cannot be mixed into an MIT package. That is why this gem ships no
|
|
1717
|
-
|
|
1718
|
-
on that machine.
|
|
1722
|
+
voice: each user makes the files with their own VOICEVOX, and they stay
|
|
1723
|
+
on that machine. (The sounds it does ship are the mahjong app's effects
|
|
1724
|
+
and BGM, "The sound".)
|
|
1725
|
+
|
|
1726
|
+
### The sound
|
|
1727
|
+
|
|
1728
|
+
(games 0.21.0) The page plays the sound of Family mruby's mahjong app:
|
|
1729
|
+
its BGM for the scene and its effects and jingles for what happens, the
|
|
1730
|
+
same sounds at the same moments as the app. They are files in the gem
|
|
1731
|
+
(`lib/asterism_games/commentate/assets/sounds/`), made beforehand with
|
|
1732
|
+
Family mruby's own APU emulator from the app's notes and its NSF tunes
|
|
1733
|
+
(not played again by ear), and the page plays them with Web Audio after
|
|
1734
|
+
the first click (the browser's autoplay rule).
|
|
1735
|
+
|
|
1736
|
+
- **The BGM** follows the app's scenes: the lobby (a table waiting, or no
|
|
1737
|
+
table yet) "After The Rain", the game and its results "Spring Thing",
|
|
1738
|
+
the tense part (a riichi on the table, or 10 tiles or fewer left)
|
|
1739
|
+
"Encounter", nothing at the end of a game (the fanfare plays alone). A
|
|
1740
|
+
tune starts from its beginning and repeats its loop without a seam.
|
|
1741
|
+
**B** (or the bar's button) steps it off, low, mid, high: the app's
|
|
1742
|
+
levels (low is 30 % of the tune's full level, mid 55 %, high 100 %, the
|
|
1743
|
+
effects always at theirs), **low by default**.
|
|
1744
|
+
- **Ducking.** While the voice speaks (VOICEVOX or the browser's), the BGM
|
|
1745
|
+
goes down to half its level, and back up a moment after the voice stops
|
|
1746
|
+
(not between two lines). The effects are not lowered.
|
|
1747
|
+
- **The effects** come with each new view of the table, beside the voice
|
|
1748
|
+
(never waiting in its queue): a round's start, the jingles of a call
|
|
1749
|
+
(チー, ポン, カン), of a riichi, of a win (ロン, ツモ) followed by the
|
|
1750
|
+
winner's points, a draw of the round and the abortive draws, a discard
|
|
1751
|
+
(the app's sound for another player's discard, whoever made it), and
|
|
1752
|
+
the fanfare at the end of the game (the app's spectator's: the
|
|
1753
|
+
winner's). As for the app's spectator, a move sounds its discard only:
|
|
1754
|
+
the player's own sounds (a draw, the turn coming) are not played. They
|
|
1755
|
+
share the APU's four channels as in the app: a sound of a higher
|
|
1756
|
+
priority still sounding keeps its channels, otherwise the newest wins.
|
|
1757
|
+
A voice that would start on top of a jingle waits for most of it (at
|
|
1758
|
+
most 0.6 s). **E** turns the effects off and on. The page remembers
|
|
1759
|
+
both switches (the browser's local storage).
|
|
1760
|
+
- **Which sound, when** is a pure function of two views
|
|
1761
|
+
(`commentate/mahjong_sounds.rb`), and each `state` event carries the
|
|
1762
|
+
BGM's scene (`bgm`) and the effects (`sounds`, `[name, delay ms]`).
|
|
1763
|
+
- **The files.** The effects are WAVs (mono, 16-bit, the APU's 15720 Hz,
|
|
1764
|
+
under 40 KB each); the three tunes are MP3s (24 kHz mono, 64 kbit/s,
|
|
1765
|
+
about 1 MB each: the tune from its start through one pass of its loop,
|
|
1766
|
+
115 to 154 s). `sounds.json` lists each with its SHA-256, the loop
|
|
1767
|
+
points, the app's levels and scenes. They are made by fmruby-core's
|
|
1768
|
+
`tools/mahjong_sounds.rb` (`ruby tools/mahjong_sounds.rb <dir>`;
|
|
1769
|
+
`--check <dir>` makes them again elsewhere and says whether any differs).
|
|
1770
|
+
- **Their licenses** (`assets/SOUNDS_LICENSE.txt`): the effects are the
|
|
1771
|
+
app's author's and are under the MIT License in this gem, by his
|
|
1772
|
+
decision (fmruby-core keeps them under GPL-3.0). **The BGM is not MIT**:
|
|
1773
|
+
the tunes are by shiru8bit under CC BY 3.0, unchanged (only played from
|
|
1774
|
+
their NSF files and saved as MP3). The page shows
|
|
1775
|
+
「BGM: shiru8bit (CC BY 3.0)」 in its corner while the BGM is on, beside
|
|
1776
|
+
the voice's credit; put it in a stream's description too.
|
|
1719
1777
|
|
|
1720
1778
|
### The AI's one-liners
|
|
1721
1779
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.21.0
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
The sounds in sounds/
|
|
2
|
+
|
|
3
|
+
These are the sound of Family mruby's mahjong app (fmruby-core,
|
|
4
|
+
flash/app/game/mahjong/mahjong_sound.rb), which the commentary page plays:
|
|
5
|
+
the effects and jingles, and the BGM of its scenes. They were not played
|
|
6
|
+
again by ear: fmruby-core's tools/mahjong_sounds.rb plays the app's notes
|
|
7
|
+
and tunes with Family mruby's own APU emulator (components/apu_emu) and
|
|
8
|
+
writes the files; sounds/sounds.json lists each file with its SHA-256 and
|
|
9
|
+
what it was made from.
|
|
10
|
+
|
|
11
|
+
1. The effects and jingles (sounds/*.wav)
|
|
12
|
+
|
|
13
|
+
discard, discard_other, draw, turn, round, yaku, points, riichi, pon,
|
|
14
|
+
chi, kan, ron, tsumo, kyuushu, ryuukyoku, fanfare1, fanfare2, fanfare3,
|
|
15
|
+
fanfare4
|
|
16
|
+
|
|
17
|
+
Written by Katsuhiko Kageyama, the author of Family mruby and of this
|
|
18
|
+
gem, as notes for the APU. In this gem they are under the MIT License,
|
|
19
|
+
the license of the gem (LICENSE), by the author's decision. In
|
|
20
|
+
fmruby-core they stay under that repository's license (GPL-3.0).
|
|
21
|
+
|
|
22
|
+
MIT License
|
|
23
|
+
|
|
24
|
+
Copyright (c) 2026 Katsuhiko Kageyama
|
|
25
|
+
|
|
26
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
27
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
28
|
+
in the Software without restriction, including without limitation the rights
|
|
29
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
30
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
31
|
+
furnished to do so, subject to the following conditions:
|
|
32
|
+
|
|
33
|
+
The above copyright notice and this permission notice shall be included in all
|
|
34
|
+
copies or substantial portions of the Software.
|
|
35
|
+
|
|
36
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
37
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
38
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
39
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
40
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
41
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
42
|
+
SOFTWARE.
|
|
43
|
+
|
|
44
|
+
2. The BGM (sounds/*.mp3): music by shiru8bit, CC BY 3.0
|
|
45
|
+
|
|
46
|
+
NOT under the MIT License. These three tunes are by shiru8bit and are
|
|
47
|
+
licensed under the Creative Commons Attribution 3.0 Unported license
|
|
48
|
+
(CC BY 3.0, https://creativecommons.org/licenses/by/3.0/), as the NSF
|
|
49
|
+
files Family mruby ships (fmruby-core, flash/usr/share/sounds/nsf/).
|
|
50
|
+
|
|
51
|
+
after_the_rain.mp3 "8-bit Chiptune - After The Rain" by shiru8bit
|
|
52
|
+
https://opengameart.org/content/8-bit-chiptune-after-the-rain
|
|
53
|
+
spring_thing.mp3 "8-bit Chiptune - Spring Thing" by shiru8bit
|
|
54
|
+
https://opengameart.org/content/8-bit-chiptune-spring-thing
|
|
55
|
+
encounter.mp3 "8-bit Chiptune - Encounter" by shiru8bit
|
|
56
|
+
https://opengameart.org/node/100497
|
|
57
|
+
|
|
58
|
+
The music is not changed. Each file is the original NSF played by
|
|
59
|
+
Family mruby's APU emulator and saved as MP3: from the start of the tune
|
|
60
|
+
through one pass of its loop (and a second more); the page repeats the
|
|
61
|
+
loop as the NSF does. Whoever passes these files on, or plays them in a
|
|
62
|
+
stream or a recording, gives the same credit, for example
|
|
63
|
+
"BGM: shiru8bit (CC BY 3.0)" (the page shows it in its corner).
|
|
@@ -80,19 +80,24 @@ body.obs #captions { max-height: none; background: rgba(0, 0, 0, 0.55); }
|
|
|
80
80
|
.cap.ai span { color: var(--ai); font-style: italic; }
|
|
81
81
|
.cap.ai.old span { color: var(--dim); }
|
|
82
82
|
#keys { padding: 0 12px 8px; color: var(--dim); font-size: 12px; }
|
|
83
|
-
/* The
|
|
84
|
-
#
|
|
83
|
+
/* The credits (VOICEVOX:<character>, the BGM's), in the corner, on a stream too. */
|
|
84
|
+
#credits {
|
|
85
85
|
position: fixed;
|
|
86
86
|
right: 8px;
|
|
87
87
|
bottom: 6px;
|
|
88
|
+
display: flex;
|
|
89
|
+
gap: 6px;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
}
|
|
92
|
+
#credits span {
|
|
88
93
|
padding: 1px 6px;
|
|
89
94
|
font-size: 12px;
|
|
90
95
|
color: var(--dim);
|
|
91
96
|
background: rgba(0, 0, 0, 0.45);
|
|
92
97
|
border-radius: 3px;
|
|
93
|
-
pointer-events: none;
|
|
94
98
|
}
|
|
95
|
-
#
|
|
99
|
+
#credits span[hidden] { display: none; }
|
|
100
|
+
#bar .btn { cursor: pointer; user-select: none; }
|
|
96
101
|
@media (max-width: 600px) {
|
|
97
102
|
#captions { font-size: 16px; }
|
|
98
103
|
.cap.p3 span { font-size: 18px; }
|
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
// the browser's speech synthesis.
|
|
9
9
|
// The AI's one-liners (l.ai) come after the fixed lines, marked "AI" in
|
|
10
10
|
// the captions, and are the first to go when the voice falls behind.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// The sound of Family mruby's mahjong app (README "The sound"): its BGM
|
|
12
|
+
// for the scene ("state"'s bgm) and its effects for each step ("state"'s
|
|
13
|
+
// sounds), files made with the app's own APU (/sounds/, sounds.json),
|
|
14
|
+
// played with Web Audio beside the voice; the BGM goes down while the
|
|
15
|
+
// voice speaks.
|
|
16
|
+
// Keys: Space pause, M voice, B BGM (off, low, mid, high), E effects,
|
|
17
|
+
// H hide the hands. URL parameters: table=<id>, hands=0 (hidden), voice=0,
|
|
18
|
+
// obs=1 (no bar, a transparent background).
|
|
13
19
|
"use strict";
|
|
14
20
|
|
|
15
21
|
(function () {
|
|
@@ -29,7 +35,7 @@
|
|
|
29
35
|
voiceOn: params.get("voice") !== "0",
|
|
30
36
|
unlocked: false, // the page was clicked: the browser lets it speak
|
|
31
37
|
paused: false,
|
|
32
|
-
held: { view: null, lines: [] },
|
|
38
|
+
held: { view: null, lines: [], bgm: null },
|
|
33
39
|
connected: false,
|
|
34
40
|
sheets: null,
|
|
35
41
|
};
|
|
@@ -399,7 +405,7 @@
|
|
|
399
405
|
const e = { t: Date.now(), kind: kind, id: item ? item.line.id : null, prio: item ? item.prio : null, src: src };
|
|
400
406
|
played.push(e);
|
|
401
407
|
if (played.length > 500) played.shift();
|
|
402
|
-
if (window.console) console.log("voice", kind, e.id, src);
|
|
408
|
+
if (window.console) console.log("voice", kind, e.id, src, e.t);
|
|
403
409
|
}
|
|
404
410
|
|
|
405
411
|
// Queues the line's voice; false when nothing is said (the caption then
|
|
@@ -468,16 +474,20 @@
|
|
|
468
474
|
if (!item) return;
|
|
469
475
|
speech.speaking = true;
|
|
470
476
|
speech.current = item;
|
|
477
|
+
sound.duck();
|
|
471
478
|
showLine(item.line);
|
|
472
479
|
sayItem(item).then(() => {
|
|
473
480
|
if (speech.current !== item) return; // cut off (M, H)
|
|
474
481
|
speech.current = null;
|
|
475
482
|
speech.speaking = false;
|
|
483
|
+
sound.duck();
|
|
476
484
|
setTimeout(next, 120);
|
|
477
485
|
});
|
|
478
486
|
}
|
|
479
487
|
|
|
480
488
|
async function sayItem(item) {
|
|
489
|
+
await sound.afterJingle();
|
|
490
|
+
if (speech.current !== item) return;
|
|
481
491
|
if (item.clips || item.blob) {
|
|
482
492
|
for (const c of item.clips || []) {
|
|
483
493
|
if (speech.current !== item) return;
|
|
@@ -536,6 +546,7 @@
|
|
|
536
546
|
drop(() => true, "cut");
|
|
537
547
|
speech.current = null;
|
|
538
548
|
speech.speaking = false;
|
|
549
|
+
sound.duck();
|
|
539
550
|
}
|
|
540
551
|
|
|
541
552
|
function showVoice() {
|
|
@@ -559,17 +570,20 @@
|
|
|
559
570
|
if (st.unlocked) return;
|
|
560
571
|
st.unlocked = true;
|
|
561
572
|
$("hint").hidden = true;
|
|
573
|
+
sound.start();
|
|
562
574
|
// A first sound inside the gesture opens the way on some browsers.
|
|
563
575
|
const v = vv();
|
|
564
576
|
if (st.voiceOn && canSpeak()) {
|
|
565
577
|
const item = { line: { id: 0 }, prio: 1, at: Date.now() };
|
|
566
578
|
speech.speaking = true;
|
|
567
579
|
speech.current = item;
|
|
580
|
+
sound.duck();
|
|
568
581
|
const p = v && v.start ? (log("clip", item, v.start), playUrl(v.start)) : speak(item, "実況を始めます");
|
|
569
582
|
p.then(() => {
|
|
570
583
|
if (speech.current !== item) return;
|
|
571
584
|
speech.current = null;
|
|
572
585
|
speech.speaking = false;
|
|
586
|
+
sound.duck();
|
|
573
587
|
next();
|
|
574
588
|
});
|
|
575
589
|
}
|
|
@@ -596,6 +610,270 @@
|
|
|
596
610
|
}
|
|
597
611
|
}
|
|
598
612
|
|
|
613
|
+
// ------------------------------------------------------------ the app's sound
|
|
614
|
+
|
|
615
|
+
// The BGM and the effects of Family mruby's mahjong app, as files made
|
|
616
|
+
// with its APU (sounds.json lists them: the effects' priorities and
|
|
617
|
+
// channels, the tunes' loops, the app's BGM levels and scenes). Web Audio,
|
|
618
|
+
// after the first click (the browser's autoplay rule): the BGM is the
|
|
619
|
+
// scene's tune from its start, its loop repeated without a seam; the
|
|
620
|
+
// levels are the app's (off, low, mid, high: low by default, as the
|
|
621
|
+
// app's), and while the voice speaks the BGM is at DUCK of its level
|
|
622
|
+
// (the effects stay as they are). An effect plays at once, beside the
|
|
623
|
+
// voice (not in its queue), and claims the APU channels it uses as the
|
|
624
|
+
// app's do: one of a higher priority still sounding on them keeps them
|
|
625
|
+
// (the new one is left out when it gets none), otherwise the older
|
|
626
|
+
// sounds on them stop. A voice that would start on top of a jingle
|
|
627
|
+
// waits for most of it (at most JINGLE_WAIT_MS). B and E switch, and the
|
|
628
|
+
// page remembers both (localStorage).
|
|
629
|
+
const sound = {
|
|
630
|
+
ctx: null,
|
|
631
|
+
manifest: null,
|
|
632
|
+
buffers: {}, // file => AudioBuffer (or a Promise of one)
|
|
633
|
+
bgmLevel: null,
|
|
634
|
+
seOn: true,
|
|
635
|
+
scene: "lobby",
|
|
636
|
+
bgm: null, // {key, src, gain} playing
|
|
637
|
+
bgmGain: null,
|
|
638
|
+
seGain: null,
|
|
639
|
+
ducked: false,
|
|
640
|
+
duckTimer: null,
|
|
641
|
+
holders: [null, null, null, null], // per APU channel: {pri, until, src}
|
|
642
|
+
jingleUntil: 0,
|
|
643
|
+
paused: false,
|
|
644
|
+
log: [],
|
|
645
|
+
DUCK: 0.5,
|
|
646
|
+
JINGLE_WAIT_MS: 600,
|
|
647
|
+
STORE: "asterism-games.commentate.",
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
sound.note = function (kind, name) {
|
|
651
|
+
const e = { t: Date.now(), kind: kind, name: name };
|
|
652
|
+
sound.log.push(e);
|
|
653
|
+
if (sound.log.length > 500) sound.log.shift();
|
|
654
|
+
if (window.console) console.log("sound", kind, name, e.t);
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
function stored(key) {
|
|
658
|
+
try { return window.localStorage.getItem(sound.STORE + key); } catch (e) { return null; }
|
|
659
|
+
}
|
|
660
|
+
function store(key, value) {
|
|
661
|
+
try { window.localStorage.setItem(sound.STORE + key, String(value)); } catch (e) { /* not kept */ }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
sound.levels = function () { return (sound.manifest && sound.manifest.bgm_levels) || [0, 0.3, 0.55, 1]; };
|
|
665
|
+
sound.names = function () { return (sound.manifest && sound.manifest.bgm_names) || ["切", "小", "中", "大"]; };
|
|
666
|
+
|
|
667
|
+
sound.load = function () {
|
|
668
|
+
const lv = parseInt(stored("bgm"), 10);
|
|
669
|
+
sound.bgmLevel = lv >= 0 && lv <= 3 ? lv : null;
|
|
670
|
+
sound.seOn = stored("se") !== "0";
|
|
671
|
+
fetch("/sounds/sounds.json").then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
|
|
672
|
+
.then((m) => {
|
|
673
|
+
sound.manifest = m;
|
|
674
|
+
if (sound.bgmLevel === null || sound.bgmLevel >= m.bgm_levels.length) sound.bgmLevel = m.bgm_default;
|
|
675
|
+
sound.show();
|
|
676
|
+
if (sound.ctx) { sound.preload(); sound.applyBgm(); }
|
|
677
|
+
})
|
|
678
|
+
.catch(() => { sound.manifest = null; sound.show(); });
|
|
679
|
+
sound.show();
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// The first click: the context, the two volumes, the effects' files.
|
|
683
|
+
sound.start = function () {
|
|
684
|
+
if (sound.ctx) return;
|
|
685
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
686
|
+
if (!AC) { sound.show(); return; }
|
|
687
|
+
// 24 kHz is plenty for the APU's sound (15.72 kHz) and halves what the
|
|
688
|
+
// decoded tunes take; a browser that will not have it gets its own rate.
|
|
689
|
+
try { sound.ctx = new AC({ sampleRate: 24000 }); } catch (e) { sound.ctx = new AC(); }
|
|
690
|
+
sound.bgmGain = sound.ctx.createGain();
|
|
691
|
+
sound.bgmGain.gain.value = sound.bgmVolume();
|
|
692
|
+
sound.bgmGain.connect(sound.ctx.destination);
|
|
693
|
+
sound.seGain = sound.ctx.createGain();
|
|
694
|
+
sound.seGain.connect(sound.ctx.destination);
|
|
695
|
+
if (sound.ctx.state === "suspended") sound.ctx.resume().catch(() => {});
|
|
696
|
+
if (sound.manifest) { sound.preload(); sound.applyBgm(); }
|
|
697
|
+
sound.show();
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
sound.buffer = function (file) {
|
|
701
|
+
const b = sound.buffers[file];
|
|
702
|
+
if (b) return b instanceof Promise ? b : Promise.resolve(b);
|
|
703
|
+
const p = fetch("/sounds/" + file)
|
|
704
|
+
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error("HTTP " + r.status))))
|
|
705
|
+
.then((data) => new Promise((resolve, reject) => sound.ctx.decodeAudioData(data, resolve, reject)))
|
|
706
|
+
.then((buf) => { sound.buffers[file] = buf; return buf; })
|
|
707
|
+
.catch((e) => { delete sound.buffers[file]; sound.note("failed", file); throw e; });
|
|
708
|
+
sound.buffers[file] = p;
|
|
709
|
+
return p;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
sound.preload = function () {
|
|
713
|
+
Object.keys(sound.manifest.effects).forEach((n) => sound.buffer(sound.manifest.effects[n].file).catch(() => {}));
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
sound.bgmVolume = function () {
|
|
717
|
+
const v = sound.levels()[sound.bgmLevel || 0] || 0;
|
|
718
|
+
return sound.ducked ? v * sound.DUCK : v;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
sound.setScene = function (scene) {
|
|
722
|
+
if (scene === sound.scene) return;
|
|
723
|
+
sound.scene = scene;
|
|
724
|
+
sound.applyBgm();
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// The tune of the scene, or none (off, the end of a game, paused).
|
|
728
|
+
sound.applyBgm = function () {
|
|
729
|
+
const m = sound.manifest;
|
|
730
|
+
if (!sound.ctx || !m) return;
|
|
731
|
+
let key = m.scenes[sound.scene] || null;
|
|
732
|
+
if (!key && sound.scene === "tense") key = m.scenes.play || null;
|
|
733
|
+
if (!sound.bgmLevel || sound.paused) key = null;
|
|
734
|
+
const cur = sound.bgm;
|
|
735
|
+
if (cur && cur.key === key) return;
|
|
736
|
+
if (cur) {
|
|
737
|
+
const t = sound.ctx.currentTime;
|
|
738
|
+
cur.gain.gain.setTargetAtTime(0, t, 0.03);
|
|
739
|
+
cur.src.stop(t + 0.2);
|
|
740
|
+
sound.bgm = null;
|
|
741
|
+
}
|
|
742
|
+
if (!key) return;
|
|
743
|
+
const tune = m.tunes[key];
|
|
744
|
+
const want = { key: key, src: null, gain: null };
|
|
745
|
+
sound.bgm = want;
|
|
746
|
+
sound.buffer(tune.file).then((buf) => {
|
|
747
|
+
if (sound.bgm !== want) return;
|
|
748
|
+
const src = sound.ctx.createBufferSource();
|
|
749
|
+
src.buffer = buf;
|
|
750
|
+
src.loop = true;
|
|
751
|
+
src.loopStart = tune.loop_start;
|
|
752
|
+
src.loopEnd = tune.loop_start + tune.loop_s;
|
|
753
|
+
const g = sound.ctx.createGain();
|
|
754
|
+
src.connect(g);
|
|
755
|
+
g.connect(sound.bgmGain);
|
|
756
|
+
src.start();
|
|
757
|
+
want.src = src;
|
|
758
|
+
want.gain = g;
|
|
759
|
+
sound.note("bgm", key);
|
|
760
|
+
}).catch(() => { if (sound.bgm === want) sound.bgm = null; });
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// An effect of the app (name: sounds.json's effects), delay ms from now.
|
|
764
|
+
sound.effect = function (name, delay) {
|
|
765
|
+
if (!sound.ctx || !sound.seOn || sound.paused || !sound.manifest) return;
|
|
766
|
+
const e = sound.manifest.effects[name];
|
|
767
|
+
if (!e) return;
|
|
768
|
+
const go = () => {
|
|
769
|
+
if (!sound.seOn || sound.paused) return;
|
|
770
|
+
sound.buffer(e.file).then((buf) => sound.play(name, e, buf)).catch(() => {});
|
|
771
|
+
};
|
|
772
|
+
if (delay > 0) setTimeout(go, delay); else go();
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
sound.play = function (name, e, buf) {
|
|
776
|
+
const now = Date.now();
|
|
777
|
+
const free = e.channels.filter((ch) => {
|
|
778
|
+
const h = sound.holders[ch];
|
|
779
|
+
return !h || h.until <= now || e.priority >= h.pri;
|
|
780
|
+
});
|
|
781
|
+
if (!free.length) { sound.note("left out", name); return; }
|
|
782
|
+
const src = sound.ctx.createBufferSource();
|
|
783
|
+
src.buffer = buf;
|
|
784
|
+
src.connect(sound.seGain);
|
|
785
|
+
const until = now + buf.duration * 1000;
|
|
786
|
+
free.forEach((ch) => {
|
|
787
|
+
const h = sound.holders[ch];
|
|
788
|
+
if (h && h.until > now && h.src !== src) {
|
|
789
|
+
try { h.src.stop(); } catch (x) { /* ended */ }
|
|
790
|
+
}
|
|
791
|
+
sound.holders[ch] = { pri: e.priority, until: until, src: src };
|
|
792
|
+
});
|
|
793
|
+
src.start();
|
|
794
|
+
if (e.priority >= 3) sound.jingleUntil = Math.max(sound.jingleUntil, until);
|
|
795
|
+
sound.note("sfx", name);
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// Resolves when a voice may start: after most of a sounding jingle.
|
|
799
|
+
sound.afterJingle = function () {
|
|
800
|
+
const wait = Math.min(sound.JINGLE_WAIT_MS, sound.jingleUntil - Date.now() - 120);
|
|
801
|
+
return wait > 0 ? new Promise((resolve) => setTimeout(resolve, wait)) : Promise.resolve();
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// The BGM down while the voice speaks; up again a moment after it stops
|
|
805
|
+
// (not between two lines).
|
|
806
|
+
sound.duck = function () {
|
|
807
|
+
const on = speech.speaking;
|
|
808
|
+
if (on) {
|
|
809
|
+
if (sound.duckTimer) { clearTimeout(sound.duckTimer); sound.duckTimer = null; }
|
|
810
|
+
if (!sound.ducked) { sound.ducked = true; sound.volume(0.05); sound.note("duck", "down"); }
|
|
811
|
+
} else if (sound.ducked && !sound.duckTimer) {
|
|
812
|
+
sound.duckTimer = setTimeout(() => {
|
|
813
|
+
sound.duckTimer = null;
|
|
814
|
+
if (speech.speaking) return;
|
|
815
|
+
sound.ducked = false;
|
|
816
|
+
sound.volume(0.25);
|
|
817
|
+
sound.note("duck", "up");
|
|
818
|
+
}, 600);
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
sound.volume = function (tc) {
|
|
823
|
+
if (!sound.bgmGain) return;
|
|
824
|
+
const t = sound.ctx.currentTime;
|
|
825
|
+
sound.bgmGain.gain.cancelScheduledValues(t);
|
|
826
|
+
sound.bgmGain.gain.setTargetAtTime(sound.bgmVolume(), t, tc || 0.05);
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
sound.cycleBgm = function () {
|
|
830
|
+
const n = sound.levels().length;
|
|
831
|
+
sound.bgmLevel = ((sound.bgmLevel || 0) + 1) % n;
|
|
832
|
+
store("bgm", sound.bgmLevel);
|
|
833
|
+
sound.volume(0.05);
|
|
834
|
+
sound.applyBgm();
|
|
835
|
+
sound.show();
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
sound.toggleSe = function () {
|
|
839
|
+
sound.seOn = !sound.seOn;
|
|
840
|
+
store("se", sound.seOn ? 1 : 0);
|
|
841
|
+
if (!sound.seOn) {
|
|
842
|
+
sound.holders.forEach((h) => { if (h) { try { h.src.stop(); } catch (x) { /* ended */ } } });
|
|
843
|
+
sound.holders = [null, null, null, null];
|
|
844
|
+
}
|
|
845
|
+
sound.show();
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
sound.pause = function (p) {
|
|
849
|
+
sound.paused = p;
|
|
850
|
+
if (!sound.ctx) return;
|
|
851
|
+
if (p) sound.ctx.suspend().catch(() => {});
|
|
852
|
+
else sound.ctx.resume().catch(() => {});
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
sound.show = function () {
|
|
856
|
+
const b = $("bgm");
|
|
857
|
+
const e = $("se");
|
|
858
|
+
const credit = $("bgmcredit");
|
|
859
|
+
if (!sound.manifest) {
|
|
860
|
+
b.textContent = "BGM: なし";
|
|
861
|
+
b.className = "item";
|
|
862
|
+
e.textContent = "";
|
|
863
|
+
credit.hidden = true;
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const lv = sound.bgmLevel || 0;
|
|
867
|
+
const wait = st.unlocked ? "" : " (クリック待ち)";
|
|
868
|
+
b.textContent = "BGM " + sound.names()[lv] + " [B]" + (lv ? wait : "");
|
|
869
|
+
b.className = "item btn" + (lv ? " on" : "");
|
|
870
|
+
e.textContent = "効果音 " + (sound.seOn ? "入" : "切") + " [E]";
|
|
871
|
+
e.className = "item btn" + (sound.seOn ? " on" : "");
|
|
872
|
+
credit.hidden = !lv;
|
|
873
|
+
};
|
|
874
|
+
$("bgm").addEventListener("click", () => sound.cycleBgm());
|
|
875
|
+
$("se").addEventListener("click", () => sound.toggleSe());
|
|
876
|
+
|
|
599
877
|
// ------------------------------------------------------------ the bar
|
|
600
878
|
|
|
601
879
|
function showBar() {
|
|
@@ -627,6 +905,7 @@
|
|
|
627
905
|
$("conn").className = "item warn";
|
|
628
906
|
showVoice();
|
|
629
907
|
showAI();
|
|
908
|
+
sound.show();
|
|
630
909
|
}
|
|
631
910
|
|
|
632
911
|
$("tables").addEventListener("change", (e) => {
|
|
@@ -644,6 +923,7 @@
|
|
|
644
923
|
|
|
645
924
|
document.addEventListener("keydown", (e) => {
|
|
646
925
|
if (e.target && e.target.tagName === "SELECT") return;
|
|
926
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
647
927
|
if (e.key === " ") {
|
|
648
928
|
e.preventDefault();
|
|
649
929
|
setPaused(!st.paused);
|
|
@@ -652,6 +932,12 @@
|
|
|
652
932
|
if (!st.voiceOn) cut();
|
|
653
933
|
unlock();
|
|
654
934
|
showVoice();
|
|
935
|
+
} else if (e.key === "b" || e.key === "B") {
|
|
936
|
+
sound.cycleBgm();
|
|
937
|
+
unlock();
|
|
938
|
+
} else if (e.key === "e" || e.key === "E") {
|
|
939
|
+
sound.toggleSe();
|
|
940
|
+
unlock();
|
|
655
941
|
} else if (e.key === "h" || e.key === "H") {
|
|
656
942
|
st.hide = !st.hide;
|
|
657
943
|
cut();
|
|
@@ -670,10 +956,12 @@
|
|
|
670
956
|
st.paused = p;
|
|
671
957
|
if (synth) { if (p) synth.pause(); else synth.resume(); }
|
|
672
958
|
if (speech.audio) { if (p) speech.audio.pause(); else speech.audio.play().catch(() => {}); }
|
|
959
|
+
sound.pause(p);
|
|
673
960
|
if (!p) {
|
|
674
961
|
if (st.held.view) st.view = st.held.view;
|
|
962
|
+
if (st.held.bgm) sound.setScene(st.held.bgm);
|
|
675
963
|
const held = st.held.lines;
|
|
676
|
-
st.held = { view: null, lines: [] };
|
|
964
|
+
st.held = { view: null, lines: [], bgm: null };
|
|
677
965
|
const big = held.filter((l) => (l.prio || 0) >= 3).pop();
|
|
678
966
|
held.forEach((l) => { st.lines.push(l); if (l !== big) addCaption(l); });
|
|
679
967
|
if (big && !say(big)) addCaption(big);
|
|
@@ -707,8 +995,10 @@
|
|
|
707
995
|
const d = JSON.parse(e.data);
|
|
708
996
|
if (st.table && d.table !== st.table) return;
|
|
709
997
|
if (!st.table) st.table = d.table;
|
|
710
|
-
if (st.paused) { st.held.view = d.view; return; }
|
|
998
|
+
if (st.paused) { st.held.view = d.view; st.held.bgm = d.bgm; return; }
|
|
711
999
|
st.view = d.view;
|
|
1000
|
+
sound.setScene(d.bgm || "lobby");
|
|
1001
|
+
(d.sounds || []).forEach((x) => sound.effect(x[0], x[1]));
|
|
712
1002
|
draw();
|
|
713
1003
|
showBar();
|
|
714
1004
|
});
|
|
@@ -730,6 +1020,7 @@
|
|
|
730
1020
|
draw();
|
|
731
1021
|
showBar();
|
|
732
1022
|
connect();
|
|
1023
|
+
sound.load();
|
|
733
1024
|
// For a test or a tool driving the page.
|
|
734
|
-
window.commentate = { state: st, played: played, speech: speech };
|
|
1025
|
+
window.commentate = { state: st, played: played, speech: speech, sound: sound };
|
|
735
1026
|
})();
|