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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +65 -7
  4. data/VERSION +1 -1
  5. data/lib/asterism_games/commentate/assets/SOUNDS_LICENSE.txt +63 -0
  6. data/lib/asterism_games/commentate/assets/commentate.css +9 -4
  7. data/lib/asterism_games/commentate/assets/commentate.js +298 -7
  8. data/lib/asterism_games/commentate/assets/index.html +5 -3
  9. data/lib/asterism_games/commentate/assets/sounds/after_the_rain.mp3 +0 -0
  10. data/lib/asterism_games/commentate/assets/sounds/chi.wav +0 -0
  11. data/lib/asterism_games/commentate/assets/sounds/discard.wav +0 -0
  12. data/lib/asterism_games/commentate/assets/sounds/discard_other.wav +0 -0
  13. data/lib/asterism_games/commentate/assets/sounds/draw.wav +0 -0
  14. data/lib/asterism_games/commentate/assets/sounds/encounter.mp3 +0 -0
  15. data/lib/asterism_games/commentate/assets/sounds/fanfare1.wav +0 -0
  16. data/lib/asterism_games/commentate/assets/sounds/fanfare2.wav +0 -0
  17. data/lib/asterism_games/commentate/assets/sounds/fanfare3.wav +0 -0
  18. data/lib/asterism_games/commentate/assets/sounds/fanfare4.wav +0 -0
  19. data/lib/asterism_games/commentate/assets/sounds/kan.wav +0 -0
  20. data/lib/asterism_games/commentate/assets/sounds/kyuushu.wav +0 -0
  21. data/lib/asterism_games/commentate/assets/sounds/points.wav +0 -0
  22. data/lib/asterism_games/commentate/assets/sounds/pon.wav +0 -0
  23. data/lib/asterism_games/commentate/assets/sounds/riichi.wav +0 -0
  24. data/lib/asterism_games/commentate/assets/sounds/ron.wav +0 -0
  25. data/lib/asterism_games/commentate/assets/sounds/round.wav +0 -0
  26. data/lib/asterism_games/commentate/assets/sounds/ryuukyoku.wav +0 -0
  27. data/lib/asterism_games/commentate/assets/sounds/sounds.json +335 -0
  28. data/lib/asterism_games/commentate/assets/sounds/spring_thing.mp3 +0 -0
  29. data/lib/asterism_games/commentate/assets/sounds/tsumo.wav +0 -0
  30. data/lib/asterism_games/commentate/assets/sounds/turn.wav +0 -0
  31. data/lib/asterism_games/commentate/assets/sounds/yaku.wav +0 -0
  32. data/lib/asterism_games/commentate/mahjong.rb +11 -4
  33. data/lib/asterism_games/commentate/mahjong_sounds.rb +118 -0
  34. data/lib/asterism_games/commentate/server.rb +14 -2
  35. data/lib/asterism_games/commentate.rb +8 -2
  36. data/lib/asterism_games/play/mahjong.rb +3 -1
  37. metadata +27 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a35db1346246e5d20505026e701fef357536cc75fa53f22bf47878398fb01c3
4
- data.tar.gz: 79c4c46763e2104c3e4d03ba521327e9ceb5fa77fc74521cec6041eb3ca48b89
3
+ metadata.gz: e2b83742bc350a97c81593529368c69fd2143887ac42293d62c9459c28c47b91
4
+ data.tar.gz: d608f964a2f5356ebe84485ed7042558f66c150e530b34aa787a113a431f64fb
5
5
  SHA512:
6
- metadata.gz: 2b31fdedf12d610dca0c8cbd4f1ae060b5b42eeab54dee46d62f0ced96d8895468b48e84db2eae2904489448329a19ef5888cf9cb2a514684c2610d4e0937d87
7
- data.tar.gz: 9908df82204210223cecd09b651da9d12e3a27ee832433d0e57b74d4d895a7a9753c83c20b75cadfeff88ef94eb91f6564290be193b1e501a37ce209124b67d3
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
- **H** hides the hands on that page (backs instead of the tiles, and the
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 and
1605
- `/events`, a stream of Server-Sent Events (`status`, `state`, `line`)
1606
- that pushes the table and the commentary. It listens on `127.0.0.1`
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
- sound: each user makes the files with their own VOICEVOX, and they stay
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.20.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 voice's credit (VOICEVOX:<character>), in the corner, on a stream too. */
84
- #credit {
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
- #credit[hidden] { display: none; }
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
- // Keys: Space pause, M voice, H hide the hands. URL parameters: table=<id>,
12
- // hands=0 (hidden), voice=0, obs=1 (no bar, a transparent background).
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
  })();