@bongos/core 1.19.365 → 1.19.366
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.
- package/.bongos-core.json +48 -13
- package/docs/file-map.md +13 -1
- package/modules/builder-settings/event-sound-prefs.js +14 -4
- package/modules/hall-ui/public/alert.wav +0 -0
- package/modules/hall-ui/public/fanfare.wav +0 -0
- package/modules/hall-ui/public/ship.wav +0 -0
- package/modules/hall-ui/public/triumph.wav +0 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-sounds.js +176 -0
- package/scripts/gds/ship-io.js +16 -2
- package/scripts/gds/turn-end-sound.js +74 -0
- package/src/module-api.js +3 -1
- package/tests/sound_player.mjs +345 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.366",
|
|
6
|
+
"core_contract": "1.19.366",
|
|
7
|
+
"source_commit": "25f9ddadfd356311a69eb302d7b6c065046fb74d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-25T20:
|
|
9
|
+
"built_at": "2026-08-25T20:30:49.191Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 331,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1536,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1889,
|
|
20
|
+
"tree_sha256": "69e72536ccdae1d15797d10aad857a6d82a62b87f7699c1f62273750b1618a34",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1602,7 +1602,7 @@
|
|
|
1602
1602
|
{
|
|
1603
1603
|
"path": "docs/file-map.md",
|
|
1604
1604
|
"mode": "0000644",
|
|
1605
|
-
"sha256": "
|
|
1605
|
+
"sha256": "8c92a7b61b7e90dff48de1d2e9444d6f460e1d71c41c45f78554deef4c1cb30b"
|
|
1606
1606
|
},
|
|
1607
1607
|
{
|
|
1608
1608
|
"path": "docs/handoff-template.md",
|
|
@@ -2572,7 +2572,7 @@
|
|
|
2572
2572
|
{
|
|
2573
2573
|
"path": "modules/builder-settings/event-sound-prefs.js",
|
|
2574
2574
|
"mode": "0000644",
|
|
2575
|
-
"sha256": "
|
|
2575
|
+
"sha256": "a4115ffcd3b298959b4ea1b13a0c5bfd645c603ad2dd4361d1340ab645dae435"
|
|
2576
2576
|
},
|
|
2577
2577
|
{
|
|
2578
2578
|
"path": "modules/builder-settings/interaction-prefs.js",
|
|
@@ -3384,6 +3384,11 @@
|
|
|
3384
3384
|
"mode": "0000644",
|
|
3385
3385
|
"sha256": "fd1220e4b2716912a03c78b23a7d0c78e4a5f4e325547cdd5207487147aad82c"
|
|
3386
3386
|
},
|
|
3387
|
+
{
|
|
3388
|
+
"path": "modules/hall-ui/public/alert.wav",
|
|
3389
|
+
"mode": "0000644",
|
|
3390
|
+
"sha256": "d175874039e054abf211670908b33080438ffdf7d3a38b9f9a715c6d066fc21a"
|
|
3391
|
+
},
|
|
3387
3392
|
{
|
|
3388
3393
|
"path": "modules/hall-ui/public/atlas.css",
|
|
3389
3394
|
"mode": "0000644",
|
|
@@ -3509,6 +3514,11 @@
|
|
|
3509
3514
|
"mode": "0000644",
|
|
3510
3515
|
"sha256": "098fcc0a3933b6d76086f92840d3b3ddb324de7b4f25cb475d23ad4a346723df"
|
|
3511
3516
|
},
|
|
3517
|
+
{
|
|
3518
|
+
"path": "modules/hall-ui/public/fanfare.wav",
|
|
3519
|
+
"mode": "0000644",
|
|
3520
|
+
"sha256": "9885b45c517fb078c30673e3fb89faceae4cb3191096e35d8b248ef1d253f9e4"
|
|
3521
|
+
},
|
|
3512
3522
|
{
|
|
3513
3523
|
"path": "modules/hall-ui/public/gate.css",
|
|
3514
3524
|
"mode": "0000644",
|
|
@@ -3804,6 +3814,11 @@
|
|
|
3804
3814
|
"mode": "0000644",
|
|
3805
3815
|
"sha256": "8d07f24b287a54fdff6d340972d8cc80e37f8fffb4169834fe051a40e6a196ac"
|
|
3806
3816
|
},
|
|
3817
|
+
{
|
|
3818
|
+
"path": "modules/hall-ui/public/ship.wav",
|
|
3819
|
+
"mode": "0000644",
|
|
3820
|
+
"sha256": "733c8db2c083c7e7595799a7941f13c9940a5615fde1f23e28d03e2aabce1f3c"
|
|
3821
|
+
},
|
|
3807
3822
|
{
|
|
3808
3823
|
"path": "modules/hall-ui/public/style.css",
|
|
3809
3824
|
"mode": "0000644",
|
|
@@ -3829,6 +3844,11 @@
|
|
|
3829
3844
|
"mode": "0000644",
|
|
3830
3845
|
"sha256": "f2c4c154920e8f1019fb1628e187201e6e582ec61b04fa9fb9620be097f44b62"
|
|
3831
3846
|
},
|
|
3847
|
+
{
|
|
3848
|
+
"path": "modules/hall-ui/public/triumph.wav",
|
|
3849
|
+
"mode": "0000644",
|
|
3850
|
+
"sha256": "0d17be2800724ca4130197f222222fcdad167bde24c068739f5ad4bfb6d6ccd8"
|
|
3851
|
+
},
|
|
3832
3852
|
{
|
|
3833
3853
|
"path": "modules/hall-ui/public/watch.css",
|
|
3834
3854
|
"mode": "0000644",
|
|
@@ -4857,12 +4877,12 @@
|
|
|
4857
4877
|
{
|
|
4858
4878
|
"path": "package-lock.json",
|
|
4859
4879
|
"mode": "0000644",
|
|
4860
|
-
"sha256": "
|
|
4880
|
+
"sha256": "91de7354c10b9722a1189018f827cae48448db443384510dbed0c271c67b6d32"
|
|
4861
4881
|
},
|
|
4862
4882
|
{
|
|
4863
4883
|
"path": "package.json",
|
|
4864
4884
|
"mode": "0000644",
|
|
4865
|
-
"sha256": "
|
|
4885
|
+
"sha256": "b690c96c25a37783d84ce670859cdcf52ff3d15629254c379bc52d4c825e7538"
|
|
4866
4886
|
},
|
|
4867
4887
|
{
|
|
4868
4888
|
"path": "public-docs/index.html",
|
|
@@ -5624,6 +5644,11 @@
|
|
|
5624
5644
|
"mode": "0000644",
|
|
5625
5645
|
"sha256": "9b936143ae25442fefb52b98949bc33d245d4b3894d778c51926e9da75c92357"
|
|
5626
5646
|
},
|
|
5647
|
+
{
|
|
5648
|
+
"path": "scripts/gds/gen-sounds.js",
|
|
5649
|
+
"mode": "0000644",
|
|
5650
|
+
"sha256": "321fa3e48fbf0987a133d232b811feea84af8ee7b6d121b1b2a5c5c527841413"
|
|
5651
|
+
},
|
|
5627
5652
|
{
|
|
5628
5653
|
"path": "scripts/gds/git-merge-regen.js",
|
|
5629
5654
|
"mode": "0000644",
|
|
@@ -6197,7 +6222,7 @@
|
|
|
6197
6222
|
{
|
|
6198
6223
|
"path": "scripts/gds/ship-io.js",
|
|
6199
6224
|
"mode": "0000644",
|
|
6200
|
-
"sha256": "
|
|
6225
|
+
"sha256": "d881d3de837a0d13f2fcaddac7aec17969e8c2d395db238fe9bf257a0925deff"
|
|
6201
6226
|
},
|
|
6202
6227
|
{
|
|
6203
6228
|
"path": "scripts/gds/ship-land.js",
|
|
@@ -6324,6 +6349,11 @@
|
|
|
6324
6349
|
"mode": "0000644",
|
|
6325
6350
|
"sha256": "831de1914642a7dcf7c591c9cc69b37c917122e04428cb1e28fc00b07170b603"
|
|
6326
6351
|
},
|
|
6352
|
+
{
|
|
6353
|
+
"path": "scripts/gds/turn-end-sound.js",
|
|
6354
|
+
"mode": "0000644",
|
|
6355
|
+
"sha256": "cc6e8640376ada7ceff6a896007b3257c8ea4d943a900eae28433cd41bdd139d"
|
|
6356
|
+
},
|
|
6327
6357
|
{
|
|
6328
6358
|
"path": "scripts/gds/update-channel.js",
|
|
6329
6359
|
"mode": "0000644",
|
|
@@ -6727,7 +6757,7 @@
|
|
|
6727
6757
|
{
|
|
6728
6758
|
"path": "src/module-api.js",
|
|
6729
6759
|
"mode": "0000644",
|
|
6730
|
-
"sha256": "
|
|
6760
|
+
"sha256": "482e6347bf3fa30bc7f55897307f64ee6be8bf14c5186fa4f882cb53cdb92990"
|
|
6731
6761
|
},
|
|
6732
6762
|
{
|
|
6733
6763
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9199,6 +9229,11 @@
|
|
|
9199
9229
|
"mode": "0000644",
|
|
9200
9230
|
"sha256": "f9b1a7021c96c02a5eb667b2d10003b4c7e256fcaafbbc228b2585c710f1d162"
|
|
9201
9231
|
},
|
|
9232
|
+
{
|
|
9233
|
+
"path": "tests/sound_player.mjs",
|
|
9234
|
+
"mode": "0000644",
|
|
9235
|
+
"sha256": "42398e730349be5f10e07eb3ec42aa47c9a024371f2f95f96802b990ff0e6f80"
|
|
9236
|
+
},
|
|
9202
9237
|
{
|
|
9203
9238
|
"path": "tests/sound_prefs.mjs",
|
|
9204
9239
|
"mode": "0000644",
|
package/docs/file-map.md
CHANGED
|
@@ -412,12 +412,24 @@ To install on a new machine, symlink each routine's directory under `~/.claude/s
|
|
|
412
412
|
├── lifecycle-card.js ← PostToolUse: deterministic delivery of MUTATING lifecycle cards (claim/ship/release) — the write-side mirror of session-cards.js (task 1288)
|
|
413
413
|
├── pre-compact.js ← PreCompact: thin reader around eviction-policy.js — emits an eviction directive (blocks to drop + one-line recovery stubs) at the compaction seam (task 1349)
|
|
414
414
|
├── eviction-policy.js ← pure deterministic in-session compaction classifier (transcript turns → eviction set + replacement stubs); no I/O, unit-tested in tests/eviction_policy.mjs; consumed by pre-compact.js (task 1348)
|
|
415
|
-
├── stop-sound.js ←
|
|
415
|
+
├── stop-sound.js ← ⚠ ABSENT from this repo (see the note below). Superseded anyway: the turn-end sound is now `scripts/gds/turn-end-sound.js`, wired as the Stop hook in `.claude/settings.json` and cross-platform rather than macOS-only (task 1003272)
|
|
416
416
|
└── stop-refcheck.js ← Stop: deterministic backstop that catches a bare `#NNN` Bongos ref in live session text (autolinks to a 404 GitHub issue) and asks for the clickable form (follow-on to task 929)
|
|
417
417
|
```
|
|
418
418
|
|
|
419
|
+
> **⚠ THIS TREE IS STALE: 12 of the 13 files listed above are NOT in this repo** — only `secret-gate.js` exists on disk, and `.claude/settings.json` wires just `SessionStart` (three `scripts/gds/*` probes + the sound-prefs mirror), `PreToolUse` (secret-gate) and `Stop` (`scripts/gds/turn-end-sound.js`). The hook tree went the same way as `.claude/sounds/` — absent from every commit, lost when core was re-seeded to 1.18.0 (task 1003272 found and fixed the sound half of that). Do not assume a hook named here runs; check `.claude/settings.json`. Auditing and either restoring or deleting the other twelve is its own task, not a doc edit.
|
|
420
|
+
|
|
419
421
|
(Before [#671](https://example.com/builders#/task/671) these ran via `bash` shims — `sync-env-local.sh`, `conductor.sh`, `onboarding-ship-logs.sh`, `rank-change-greeting.sh`, `pull-memory.sh` — which failed on Windows without Git Bash. The Node entries above replaced them.)
|
|
420
422
|
|
|
423
|
+
**Notification sounds** at `.claude/sounds/` + `modules/hall-ui/public/*.wav` (task 1003272 — the local half of the 5-event sound system, which had never been committed):
|
|
424
|
+
|
|
425
|
+
```
|
|
426
|
+
.claude/sounds/
|
|
427
|
+
└── play.js ← the local player. playSound(<wavName>) — fire-and-forget, never throws, spawns the platform player detached (afplay / paplay / aplay / ffplay / PowerShell SoundPlayer); scales the WAV itself for the master volume because aplay and SoundPlayer have no volume flag. Reads the prefs mirror at instance-config.configPath('sound-prefs.json') with the pre-rename ~/.config/otb/ as fallback. Required (guarded) by scripts/gds/ship-state.js; runnable directly: `node .claude/sounds/play.js ship`
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The five base assets live ONCE, in `modules/hall-ui/public/` — served at `/builders/<name>.wav` for the Settings previews and read from the same directory by the player:
|
|
431
|
+
`chime.wav` (pre-existing) plus `alert.wav`, `ship.wav`, `triumph.wav`, `fanfare.wav`, all generated by [`scripts/gds/gen-sounds.js`](../scripts/gds/gen-sounds.js) (`--check` verifies freshness; it never touches `chime.wav`).
|
|
432
|
+
|
|
421
433
|
**On the droplet (`REDACTED_IP`):**
|
|
422
434
|
|
|
423
435
|
```
|
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
// src/bongos/event-sound-prefs.js — per-builder per-event sound variant selection
|
|
3
3
|
// (task 582). Extends the mute-toggle system (sound-prefs.js) with variant
|
|
4
4
|
// picking: for each of 5 build events a builder can choose which world-themed
|
|
5
|
-
// sound variant plays. Each variant maps to an EXISTING .wav
|
|
6
|
-
//
|
|
5
|
+
// sound variant plays. Each variant maps to an EXISTING base .wav; the dedicated
|
|
6
|
+
// per-variant audio (lyre-pluck.wav etc.) would ship separately.
|
|
7
|
+
//
|
|
8
|
+
// WHERE THE BASE WAVS ACTUALLY LIVE (corrected, task 1003272): modules/hall-ui/
|
|
9
|
+
// public/, served at /builders/<name>.wav and read from that same directory by
|
|
10
|
+
// .claude/sounds/play.js. This comment used to say .claude/sounds/, which was
|
|
11
|
+
// true of no commit in this repo's history — for months FOUR of the five base
|
|
12
|
+
// wavs did not exist anywhere, so every variant below except the two backed by
|
|
13
|
+
// `chime` resolved to a 404 and played nothing. All five exist now, and
|
|
14
|
+
// tests/sound_player.mjs asserts that every `wav` named here has a real file.
|
|
7
15
|
|
|
8
16
|
// 5 build events, ordered for the UI. Each has a world-themed default variant
|
|
9
|
-
// plus alternates. `wav` is the existing
|
|
10
|
-
//
|
|
17
|
+
// plus alternates. `wav` is the existing base sound that backs the variant until
|
|
18
|
+
// dedicated audio ships. Each event's two variants deliberately name DIFFERENT
|
|
19
|
+
// base wavs, so switching the picker audibly changes something — a picker whose
|
|
20
|
+
// options all sounded identical would be a worse lie than no picker.
|
|
11
21
|
const EVENTS = Object.freeze([
|
|
12
22
|
{
|
|
13
23
|
id: 'user-input',
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.366",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.366",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.366",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/gen-sounds.js — synthesize the notification WAVs (task 1003272).
|
|
3
|
+
//
|
|
4
|
+
// WHY A GENERATOR RATHER THAN FOUR COMMITTED BINARIES. The 5-event sound system
|
|
5
|
+
// (V3.R90, task 1000781) shipped its configuration — the prefs, the toggles, the
|
|
6
|
+
// master volume, the per-event variant picker — and never shipped the audio. Only
|
|
7
|
+
// chime.wav has ever existed in this repo, so four of the five events resolved to
|
|
8
|
+
// a 404 and played nothing. Two prior attempts to fix "sound is broken" (tasks
|
|
9
|
+
// 1001252 and 1000796) were abandoned without reaching that.
|
|
10
|
+
//
|
|
11
|
+
// An opaque binary nobody can regenerate is how a repo loses assets in the first
|
|
12
|
+
// place (the local player itself vanished in the 1.18.0 re-seed and no commit
|
|
13
|
+
// records it). These are simple synthesized tones, so the honest artifact is the
|
|
14
|
+
// recipe: a few dozen lines of additive synthesis anyone can read, adjust, and
|
|
15
|
+
// re-run. `node scripts/gds/gen-sounds.js` is idempotent — same bytes every time,
|
|
16
|
+
// no timestamps, no randomness — so it can be a CI freshness check like the other
|
|
17
|
+
// generators if that is ever wanted.
|
|
18
|
+
//
|
|
19
|
+
// chime.wav IS NOT TOUCHED. It predates this task and is the one asset the system
|
|
20
|
+
// always had; regenerating it would replace a sound builders already know with a
|
|
21
|
+
// different one for no reason. This writes only the four that were missing.
|
|
22
|
+
//
|
|
23
|
+
// WHERE THEY GO, and why one copy. modules/hall-ui/public/ is served at
|
|
24
|
+
// /builders/<name>.wav, which is exactly what settings.js previews
|
|
25
|
+
// (`new Audio('/builders/' + wavName + '.wav')`). The local player
|
|
26
|
+
// (.claude/sounds/play.js) reads the SAME files from that directory rather than
|
|
27
|
+
// keeping a second copy — two copies of an asset are two things to drift.
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const RATE = 44100;
|
|
35
|
+
const OUT_DIR = path.resolve(__dirname, '..', '..', 'modules', 'hall-ui', 'public');
|
|
36
|
+
|
|
37
|
+
// Equal-temperament note table, only the notes these sounds use. Named rather
|
|
38
|
+
// than numeric so the voicings below read as music instead of magic numbers.
|
|
39
|
+
const HZ = {
|
|
40
|
+
A5: 880.00, D6: 1174.66,
|
|
41
|
+
C5: 523.25, E5: 659.25, G5: 783.99,
|
|
42
|
+
C6: 1046.50, E6: 1318.51, G6: 1567.98,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// One struck note: a fundamental plus a little second and third harmonic (a bell
|
|
46
|
+
// is not a sine wave), under an exponential decay with a short fade-in. The
|
|
47
|
+
// fade-in matters — starting a waveform at full amplitude is an audible click.
|
|
48
|
+
function note({ freq, start, dur, gain = 0.5, decay = 4.5 }) {
|
|
49
|
+
const voices = [
|
|
50
|
+
{ mult: 1, amp: 1.0 },
|
|
51
|
+
{ mult: 2, amp: 0.28 },
|
|
52
|
+
{ mult: 3, amp: 0.10 },
|
|
53
|
+
];
|
|
54
|
+
const attack = Math.min(0.006, dur / 8); // seconds
|
|
55
|
+
return { freq, start, dur, gain, decay, voices, attack };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Render a list of notes into one mono float buffer, summed (notes may overlap).
|
|
59
|
+
function render(notes) {
|
|
60
|
+
const end = notes.reduce((m, n) => Math.max(m, n.start + n.dur), 0);
|
|
61
|
+
const total = Math.ceil((end + 0.03) * RATE); // a little tail so nothing cuts
|
|
62
|
+
const buf = new Float64Array(total);
|
|
63
|
+
for (const n of notes) {
|
|
64
|
+
const from = Math.floor(n.start * RATE);
|
|
65
|
+
const len = Math.floor(n.dur * RATE);
|
|
66
|
+
for (let i = 0; i < len; i++) {
|
|
67
|
+
const t = i / RATE;
|
|
68
|
+
const env = Math.exp(-n.decay * t) * Math.min(1, t / n.attack);
|
|
69
|
+
let s = 0;
|
|
70
|
+
for (const v of n.voices) s += v.amp * Math.sin(2 * Math.PI * n.freq * v.mult * t);
|
|
71
|
+
buf[from + i] += s * env * n.gain;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Normalize to a consistent peak so no sound is jarringly louder than another,
|
|
75
|
+
// then a short linear fade-out so the file never ends mid-cycle (another click).
|
|
76
|
+
let peak = 0;
|
|
77
|
+
for (let i = 0; i < total; i++) peak = Math.max(peak, Math.abs(buf[i]));
|
|
78
|
+
const scale = peak > 0 ? 0.82 / peak : 0;
|
|
79
|
+
const fade = Math.min(total, Math.floor(0.02 * RATE));
|
|
80
|
+
for (let i = 0; i < total; i++) {
|
|
81
|
+
let s = buf[i] * scale;
|
|
82
|
+
const fromEnd = total - 1 - i;
|
|
83
|
+
if (fromEnd < fade) s *= fromEnd / fade;
|
|
84
|
+
buf[i] = s;
|
|
85
|
+
}
|
|
86
|
+
return buf;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 16-bit mono PCM, 44100 Hz — byte-for-byte the format of the existing chime.wav,
|
|
90
|
+
// so nothing downstream has to care which file it got.
|
|
91
|
+
function toWav(samples) {
|
|
92
|
+
const dataBytes = samples.length * 2;
|
|
93
|
+
const out = Buffer.alloc(44 + dataBytes);
|
|
94
|
+
out.write('RIFF', 0);
|
|
95
|
+
out.writeUInt32LE(36 + dataBytes, 4);
|
|
96
|
+
out.write('WAVE', 8);
|
|
97
|
+
out.write('fmt ', 12);
|
|
98
|
+
out.writeUInt32LE(16, 16); // fmt chunk size
|
|
99
|
+
out.writeUInt16LE(1, 20); // PCM
|
|
100
|
+
out.writeUInt16LE(1, 22); // mono
|
|
101
|
+
out.writeUInt32LE(RATE, 24);
|
|
102
|
+
out.writeUInt32LE(RATE * 2, 28); // byte rate
|
|
103
|
+
out.writeUInt16LE(2, 32); // block align
|
|
104
|
+
out.writeUInt16LE(16, 34); // bits
|
|
105
|
+
out.write('data', 36);
|
|
106
|
+
out.writeUInt32LE(dataBytes, 40);
|
|
107
|
+
for (let i = 0; i < samples.length; i++) {
|
|
108
|
+
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
|
109
|
+
out.writeInt16LE(Math.round(clamped * 32767), 44 + i * 2);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The four voicings. Each is written to match what the event MEANS — the labels
|
|
115
|
+
// come from modules/builder-settings/sound-prefs.js.
|
|
116
|
+
const SOUNDS = {
|
|
117
|
+
// "When Claude needs your input" — a two-tone ask, rising so it reads as a
|
|
118
|
+
// question. Short and unhurried: this one can fire while someone is thinking.
|
|
119
|
+
alert: [
|
|
120
|
+
note({ freq: HZ.A5, start: 0.00, dur: 0.26, gain: 0.5, decay: 7 }),
|
|
121
|
+
note({ freq: HZ.D6, start: 0.15, dur: 0.40, gain: 0.5, decay: 6 }),
|
|
122
|
+
],
|
|
123
|
+
// "When a task ships" — a settled two-note rise, C5 to G5. A fifth, because it
|
|
124
|
+
// should sound finished rather than expectant.
|
|
125
|
+
ship: [
|
|
126
|
+
note({ freq: HZ.C5, start: 0.00, dur: 0.30, gain: 0.5, decay: 6 }),
|
|
127
|
+
note({ freq: HZ.G5, start: 0.14, dur: 0.55, gain: 0.5, decay: 4.5 }),
|
|
128
|
+
],
|
|
129
|
+
// "When a big task ships" — the same idea carried further: a major triad
|
|
130
|
+
// arpeggio landing on the octave.
|
|
131
|
+
triumph: [
|
|
132
|
+
note({ freq: HZ.C5, start: 0.00, dur: 0.26, gain: 0.42, decay: 7 }),
|
|
133
|
+
note({ freq: HZ.E5, start: 0.11, dur: 0.26, gain: 0.42, decay: 7 }),
|
|
134
|
+
note({ freq: HZ.G5, start: 0.22, dur: 0.30, gain: 0.46, decay: 6 }),
|
|
135
|
+
note({ freq: HZ.C6, start: 0.34, dur: 0.75, gain: 0.55, decay: 3.4 }),
|
|
136
|
+
],
|
|
137
|
+
// "When you earn a rank promotion" — the brightest of the four, a four-note
|
|
138
|
+
// fanfare that overlaps into a held top note.
|
|
139
|
+
fanfare: [
|
|
140
|
+
note({ freq: HZ.G5, start: 0.00, dur: 0.22, gain: 0.40, decay: 8 }),
|
|
141
|
+
note({ freq: HZ.C6, start: 0.10, dur: 0.22, gain: 0.44, decay: 8 }),
|
|
142
|
+
note({ freq: HZ.E6, start: 0.20, dur: 0.26, gain: 0.46, decay: 7 }),
|
|
143
|
+
note({ freq: HZ.G6, start: 0.31, dur: 0.90, gain: 0.55, decay: 3.0 }),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
const check = process.argv.includes('--check');
|
|
149
|
+
const written = [];
|
|
150
|
+
const stale = [];
|
|
151
|
+
for (const [name, notes] of Object.entries(SOUNDS)) {
|
|
152
|
+
const file = path.join(OUT_DIR, `${name}.wav`);
|
|
153
|
+
const wav = toWav(render(notes));
|
|
154
|
+
let current = null;
|
|
155
|
+
try { current = fs.readFileSync(file); } catch { /* absent */ }
|
|
156
|
+
if (current && current.equals(wav)) continue;
|
|
157
|
+
if (check) { stale.push(path.relative(path.resolve(__dirname, '..', '..'), file)); continue; }
|
|
158
|
+
fs.writeFileSync(file, wav);
|
|
159
|
+
written.push(`${name}.wav (${wav.length} bytes)`);
|
|
160
|
+
}
|
|
161
|
+
if (check) {
|
|
162
|
+
if (stale.length) {
|
|
163
|
+
console.error(`gen-sounds --check: STALE — would rewrite ${stale.join(', ')}`);
|
|
164
|
+
console.error(' run: node scripts/gds/gen-sounds.js (and commit the result)');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
console.log('gen-sounds --check: up to date.');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.log(written.length
|
|
171
|
+
? `gen-sounds: wrote ${written.join(', ')}`
|
|
172
|
+
: 'gen-sounds: all sounds already current — no changes.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (require.main === module) main();
|
|
176
|
+
module.exports = { render, toWav, SOUNDS, RATE };
|
package/scripts/gds/ship-io.js
CHANGED
|
@@ -14,8 +14,7 @@ const { playSound } = require('./ship-state.js');
|
|
|
14
14
|
function playShipBell() {
|
|
15
15
|
let shipWav = 'ship';
|
|
16
16
|
try {
|
|
17
|
-
const
|
|
18
|
-
const raw = require('fs').readFileSync(prefsPath, 'utf8');
|
|
17
|
+
const raw = require('fs').readFileSync(shipPrefsPath(), 'utf8');
|
|
19
18
|
const parsed = JSON.parse(raw);
|
|
20
19
|
const ev = parsed && parsed.event_sounds && parsed.event_sounds['task-shipped'];
|
|
21
20
|
if (typeof ev === 'string') shipWav = ev;
|
|
@@ -23,6 +22,20 @@ function playShipBell() {
|
|
|
23
22
|
playSound(shipWav);
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
// task 1003272: this used to hardcode ~/.config/otb/, the PRE-RENAME location,
|
|
26
|
+
// while fetch-sound-prefs.js writes the mirror through
|
|
27
|
+
// instance-config.configPath() — today ~/.config/cloudbongos/. Reader and writer
|
|
28
|
+
// were looking at different files, so a builder's chosen ship sound was never
|
|
29
|
+
// found and this always fell through to the 'ship' default. Resolve the SAME way
|
|
30
|
+
// the writer does, with the legacy path as a fallback for an older box.
|
|
31
|
+
function shipPrefsPath() {
|
|
32
|
+
try {
|
|
33
|
+
const p = require('../../src/instance-config').configPath('sound-prefs.json');
|
|
34
|
+
if (require('fs').existsSync(p)) return p;
|
|
35
|
+
} catch (_) { /* fall through to the legacy location */ }
|
|
36
|
+
return require('path').join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
// task 1002809: normalize an API error body across BOTH wire shapes — the live
|
|
27
40
|
// ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
|
|
28
41
|
// `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
|
|
@@ -93,6 +106,7 @@ function postLandShipOutcome({ code, liveStatus }) {
|
|
|
93
106
|
// the early returns are control flow only.
|
|
94
107
|
|
|
95
108
|
module.exports = {
|
|
109
|
+
shipPrefsPath,
|
|
96
110
|
apiErrorLine,
|
|
97
111
|
normalizeApiError,
|
|
98
112
|
playShipBell,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/turn-end-sound.js — the Stop hook that rings when a turn ends
|
|
3
|
+
// (task 1003272).
|
|
4
|
+
//
|
|
5
|
+
// THE TRIGGER THAT NEVER EXISTED. scripts/gds/fetch-sound-prefs.js has always
|
|
6
|
+
// described "a LOCAL Stop hook + ship.js via .claude/sounds/play.js", and
|
|
7
|
+
// sound-prefs.js has always labelled `chime` as "When Claude finishes a turn" —
|
|
8
|
+
// but .claude/settings.json had no Stop hook at all. So the single most
|
|
9
|
+
// noticeable sound in the system had nothing to fire it, on top of the player
|
|
10
|
+
// being absent and four of the five WAVs never having been committed. Three
|
|
11
|
+
// independent gaps, one symptom: "all sound seems bugged".
|
|
12
|
+
//
|
|
13
|
+
// WHY A SCRIPT RATHER THAN AN INLINE HOOK COMMAND. The variant resolution below
|
|
14
|
+
// is the same logic ship-io.js's playShipBell() already uses (read the mirrored
|
|
15
|
+
// prefs, take the event's chosen wav, fall back to a base sound). That is a
|
|
16
|
+
// couple of lines too many for a JSON string, and a hook that cannot be read is
|
|
17
|
+
// a hook nobody will fix.
|
|
18
|
+
//
|
|
19
|
+
// IT MUST ALWAYS EXIT 0 AND FAST. This runs at the end of every turn. It resolves
|
|
20
|
+
// a name, hands it to a fire-and-forget player that spawns detached, and returns;
|
|
21
|
+
// every failure path is a silent no-op. A Stop hook that threw, or that blocked
|
|
22
|
+
// on audio, would make a cosmetic feature everyone's problem.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
|
|
30
|
+
// `user-input` is the event whose meaning matches a turn ending — Claude has
|
|
31
|
+
// paused and it is the builder's move. Falls back to `chime`, which is the base
|
|
32
|
+
// sound sound-prefs.js labels "When Claude finishes a turn".
|
|
33
|
+
const EVENT_ID = 'user-input';
|
|
34
|
+
const FALLBACK_WAV = 'chime';
|
|
35
|
+
|
|
36
|
+
// Resolve the mirror the SAME way fetch-sound-prefs.js writes it
|
|
37
|
+
// (instance-config.configPath — today ~/.config/cloudbongos/), with the
|
|
38
|
+
// pre-rename ~/.config/otb/ as a fallback for an older box. Hardcoding the
|
|
39
|
+
// legacy path is the bug this task found in two other readers; it is not going
|
|
40
|
+
// to be re-introduced here.
|
|
41
|
+
function prefsPath() {
|
|
42
|
+
try {
|
|
43
|
+
const p = require('../../src/instance-config').configPath('sound-prefs.json');
|
|
44
|
+
if (fs.existsSync(p)) return p;
|
|
45
|
+
} catch (_) { /* fall through */ }
|
|
46
|
+
return path.join(os.homedir(), '.config', 'otb', 'sound-prefs.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveWav() {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(fs.readFileSync(prefsPath(), 'utf8'));
|
|
52
|
+
const chosen = parsed && parsed.event_sounds && parsed.event_sounds[EVENT_ID];
|
|
53
|
+
if (typeof chosen === 'string' && chosen) return chosen;
|
|
54
|
+
} catch (_) { /* no mirror yet — the fallback is the honest default */ }
|
|
55
|
+
return FALLBACK_WAV;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
try {
|
|
60
|
+
// Guarded exactly like ship-state.js's: on a checkout without the player
|
|
61
|
+
// (or mid-re-seed) this is a no-op, never a failed turn.
|
|
62
|
+
let playSound;
|
|
63
|
+
try {
|
|
64
|
+
({ playSound } = require(path.resolve(__dirname, '..', '..', '.claude', 'sounds', 'play.js')));
|
|
65
|
+
} catch (_) { return; }
|
|
66
|
+
if (typeof playSound === 'function') playSound(resolveWav());
|
|
67
|
+
} catch (_) { /* cosmetic, always */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (require.main === module) {
|
|
71
|
+
main();
|
|
72
|
+
process.exit(0); // never let a stray handle hold the turn open
|
|
73
|
+
}
|
|
74
|
+
module.exports = { resolveWav, prefsPath, EVENT_ID, FALLBACK_WAV };
|
package/src/module-api.js
CHANGED
|
@@ -1160,8 +1160,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1160
1160
|
// landed since 1.19.363 with no explicit bump. run 32890225073. (task 1002620)
|
|
1161
1161
|
// 1.19.365 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1162
1162
|
// landed since 1.19.364 with no explicit bump. run 32893443215. (task 1002620)
|
|
1163
|
+
// 1.19.366 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1164
|
+
// landed since 1.19.365 with no explicit bump. run 32895705036. (task 1002620)
|
|
1163
1165
|
// ---------------------------------------------------------------------------
|
|
1164
|
-
const CORE_VERSION = '1.19.
|
|
1166
|
+
const CORE_VERSION = '1.19.366'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1165
1167
|
|
|
1166
1168
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1167
1169
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// tests/sound_player.mjs — the local notification player and its assets
|
|
2
|
+
// (task 1003272).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS FILE EXISTS TO MAKE UNREPEATABLE. The 5-event sound system
|
|
5
|
+
// (V3.R90, task 1000781) shipped its whole CONFIGURATION — prefs, per-sound
|
|
6
|
+
// toggles, master volume, a per-event variant picker in Settings — and never
|
|
7
|
+
// shipped the audio. `git ls-files` carried exactly one WAV, so four of the five
|
|
8
|
+
// events resolved to a 404 and played nothing, and .claude/sounds/play.js, which
|
|
9
|
+
// four other files require, had never been committed at all. Two previous
|
|
10
|
+
// attempts to fix "sound is broken" (tasks 1001252, 1000796) were abandoned
|
|
11
|
+
// without reaching either fact.
|
|
12
|
+
//
|
|
13
|
+
// So the load-bearing test here is the boring one: EVERY SOUND THE SERVER CAN
|
|
14
|
+
// NAME HAS A FILE (and every file is really playable audio). That is the
|
|
15
|
+
// assertion whose absence let a shipped feature be silent for months.
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const player = require(path.join(ROOT, '.claude', 'sounds', 'play.js'));
|
|
28
|
+
const soundPrefs = require(path.join(ROOT, 'modules', 'builder-settings', 'sound-prefs.js'));
|
|
29
|
+
|
|
30
|
+
const WAV_DIR = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
31
|
+
|
|
32
|
+
function tmpPrefs(obj) {
|
|
33
|
+
const f = path.join(os.tmpdir(), `otb-test-prefs-${process.pid}-${Math.abs(hash(JSON.stringify(obj)))}.json`);
|
|
34
|
+
fs.writeFileSync(f, JSON.stringify(obj));
|
|
35
|
+
return f;
|
|
36
|
+
}
|
|
37
|
+
// A tiny stable hash so the temp name is derived from content, not a clock
|
|
38
|
+
// (Date.now()/Math.random() are avoidable here and make reruns noisier).
|
|
39
|
+
function hash(s) {
|
|
40
|
+
let h = 0;
|
|
41
|
+
for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; }
|
|
42
|
+
return h;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Minimal 16-bit mono WAV, for the scaler tests.
|
|
46
|
+
function wav(samples, { bits = 16, extraChunk = false } = {}) {
|
|
47
|
+
const data = Buffer.alloc(samples.length * 2);
|
|
48
|
+
samples.forEach((v, i) => data.writeInt16LE(v, i * 2));
|
|
49
|
+
const pre = extraChunk ? Buffer.concat([Buffer.from('LIST'), u32(4), Buffer.from('INFO')]) : Buffer.alloc(0);
|
|
50
|
+
const head = Buffer.alloc(36);
|
|
51
|
+
head.write('RIFF', 0); head.writeUInt32LE(28 + pre.length + data.length, 4); head.write('WAVE', 8);
|
|
52
|
+
head.write('fmt ', 12); head.writeUInt32LE(16, 16); head.writeUInt16LE(1, 20);
|
|
53
|
+
head.writeUInt16LE(1, 22); head.writeUInt32LE(44100, 24); head.writeUInt32LE(88200, 28);
|
|
54
|
+
head.writeUInt16LE(2, 32); head.writeUInt16LE(bits, 34);
|
|
55
|
+
return Buffer.concat([head, pre, Buffer.from('data'), u32(data.length), data]);
|
|
56
|
+
}
|
|
57
|
+
function u32(n) { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b; }
|
|
58
|
+
|
|
59
|
+
// ── the assertion the original bug needed ───────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test('EVERY sound the server can name has a real file on disk', () => {
|
|
62
|
+
// KNOWN_SOUNDS is what /me/sound-prefs offers a builder to toggle, and the
|
|
63
|
+
// event-sound variants resolve to these same stems. A name here with no file
|
|
64
|
+
// is a toggle for silence — which is exactly what shipped.
|
|
65
|
+
const known = Object.keys(soundPrefs.SOUND_DEFAULTS || {});
|
|
66
|
+
assert.ok(known.length >= 5, `expected the 5-event roster, got ${known.length}`);
|
|
67
|
+
for (const name of known) {
|
|
68
|
+
const f = path.join(WAV_DIR, `${name}.wav`);
|
|
69
|
+
assert.ok(fs.existsSync(f), `${name}.wav is MISSING — the server offers this sound and nothing can play it`);
|
|
70
|
+
assert.ok(fs.statSync(f).size > 1000, `${name}.wav exists but is suspiciously tiny`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('every asset is really 16-bit 44.1kHz mono PCM, like the one that always worked', () => {
|
|
75
|
+
// Same format as the pre-existing chime.wav, so nothing downstream has to care
|
|
76
|
+
// which file it was handed.
|
|
77
|
+
for (const name of Object.keys(soundPrefs.SOUND_DEFAULTS || {})) {
|
|
78
|
+
const b = fs.readFileSync(path.join(WAV_DIR, `${name}.wav`));
|
|
79
|
+
assert.equal(b.toString('ascii', 0, 4), 'RIFF', `${name}: not RIFF`);
|
|
80
|
+
assert.equal(b.toString('ascii', 8, 12), 'WAVE', `${name}: not WAVE`);
|
|
81
|
+
assert.equal(b.readUInt16LE(20), 1, `${name}: not PCM`);
|
|
82
|
+
assert.equal(b.readUInt16LE(22), 1, `${name}: not mono`);
|
|
83
|
+
assert.equal(b.readUInt32LE(24), 44100, `${name}: wrong sample rate`);
|
|
84
|
+
assert.equal(b.readUInt16LE(34), 16, `${name}: not 16-bit`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('EVERY event-sound variant resolves to a real file, and each event offers a real CHOICE', () => {
|
|
89
|
+
// The Settings variant picker offered names like "Lyre Pluck" and "Bronze
|
|
90
|
+
// Fanfare" that resolved to wavs which did not exist — selectable options for
|
|
91
|
+
// silence. Both halves are asserted: the file is there, and an event's variants
|
|
92
|
+
// are not all the same sound (a picker whose options are identical is a lie).
|
|
93
|
+
const eventPrefs = require(path.join(ROOT, 'modules', 'builder-settings', 'event-sound-prefs.js'));
|
|
94
|
+
const events = eventPrefs.EVENTS || eventPrefs.default || [];
|
|
95
|
+
assert.equal(events.length, 5, 'the 5-event roster');
|
|
96
|
+
for (const ev of events) {
|
|
97
|
+
assert.ok(ev.variants.length >= 2, `${ev.id}: a picker needs something to pick`);
|
|
98
|
+
for (const v of ev.variants) {
|
|
99
|
+
assert.ok(fs.existsSync(path.join(WAV_DIR, `${v.wav}.wav`)),
|
|
100
|
+
`${ev.id}/${v.id} names ${v.wav}.wav, which does not exist — a selectable silence`);
|
|
101
|
+
}
|
|
102
|
+
const distinct = new Set(ev.variants.map((v) => v.wav));
|
|
103
|
+
assert.equal(distinct.size, ev.variants.length,
|
|
104
|
+
`${ev.id}: its variants must map to DIFFERENT sounds, or switching changes nothing`);
|
|
105
|
+
assert.equal(ev.variants.filter((v) => v.isDefault).length, 1, `${ev.id}: exactly one default`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the generator is idempotent, so the assets are reproducible not mysterious', () => {
|
|
110
|
+
// --check exits non-zero if a re-run would change any byte. An asset nobody can
|
|
111
|
+
// regenerate is how this repo lost the player in the first place.
|
|
112
|
+
execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'gen-sounds.js'), '--check'], { stdio: 'pipe' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the generator does NOT touch chime.wav', () => {
|
|
116
|
+
// chime predates this task and is the sound builders already know; regenerating
|
|
117
|
+
// it would silently swap a familiar sound for a different one.
|
|
118
|
+
const gen = require(path.join(ROOT, 'scripts', 'gds', 'gen-sounds.js'));
|
|
119
|
+
assert.ok(!Object.keys(gen.SOUNDS).includes('chime'));
|
|
120
|
+
assert.deepEqual(Object.keys(gen.SOUNDS).sort(), ['alert', 'fanfare', 'ship', 'triumph']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── the require that four files depend on ───────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test("ship-state's guarded require now resolves the REAL player, not its no-op stub", () => {
|
|
126
|
+
// task 1002199 wrapped this require so a missing player could not crash ship.js.
|
|
127
|
+
// The guard stays — but it must no longer be what actually runs.
|
|
128
|
+
const shipState = require(path.join(ROOT, 'scripts', 'gds', 'ship-state.js'));
|
|
129
|
+
assert.equal(typeof shipState.playSound, 'function');
|
|
130
|
+
assert.equal(shipState.playSound.name, 'playSound',
|
|
131
|
+
'the stub is an anonymous arrow; the real one is a named function');
|
|
132
|
+
assert.ok(shipState.playSound.length >= 1, 'the real player takes a wav name');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── the writer and the readers must agree on ONE path ──────────────────────
|
|
136
|
+
|
|
137
|
+
test('every prefs READER resolves the same path the WRITER uses', () => {
|
|
138
|
+
// THE FOURTH BUG IN THIS STACK. fetch-sound-prefs.js writes the mirror through
|
|
139
|
+
// instance-config.configPath() — today ~/.config/cloudbongos/ — while three
|
|
140
|
+
// readers hardcoded ~/.config/otb/, the PRE-RENAME location. Every one of them
|
|
141
|
+
// silently found nothing: the player failed open (so the Settings toggles and
|
|
142
|
+
// master volume had no local effect at all), and the ship bell always fell back
|
|
143
|
+
// to its default instead of the builder's chosen variant. Nothing errored.
|
|
144
|
+
const ic = require(path.join(ROOT, 'src', 'instance-config.js'));
|
|
145
|
+
const canonical = ic.configPath('sound-prefs.json');
|
|
146
|
+
|
|
147
|
+
const shipIo = require(path.join(ROOT, 'scripts', 'gds', 'ship-io.js'));
|
|
148
|
+
const turnEnd = require(path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'));
|
|
149
|
+
|
|
150
|
+
// The player's canonical path must BE the writer's path.
|
|
151
|
+
assert.equal(player.PREFS_PATH, canonical, 'play.js must read where the writer writes');
|
|
152
|
+
// The other two resolve to the canonical path when it exists, else the legacy
|
|
153
|
+
// one — so assert membership rather than pinning which branch this box takes.
|
|
154
|
+
for (const [name, got] of [['ship-io', shipIo.shipPrefsPath()], ['turn-end-sound', turnEnd.prefsPath()]]) {
|
|
155
|
+
assert.ok([canonical, player.LEGACY_PREFS_PATH].includes(got),
|
|
156
|
+
`${name} resolved ${got}, which is neither the canonical nor the legacy path`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('no reader hardcodes the pre-rename otb path as its ONLY location', () => {
|
|
161
|
+
// A grep-level guard: the string may appear as a documented FALLBACK, but a
|
|
162
|
+
// reader whose only path is the legacy one is the bug returning.
|
|
163
|
+
const files = [
|
|
164
|
+
path.join(ROOT, '.claude', 'sounds', 'play.js'),
|
|
165
|
+
path.join(ROOT, 'scripts', 'gds', 'ship-io.js'),
|
|
166
|
+
path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'),
|
|
167
|
+
];
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
170
|
+
if (!src.includes("'otb'")) continue; // no legacy reference at all: fine
|
|
171
|
+
assert.ok(src.includes('instance-config'),
|
|
172
|
+
`${path.basename(f)} mentions the legacy otb path without resolving via instance-config`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the legacy prefs file is still honoured when the canonical one is absent', () => {
|
|
177
|
+
// An older box has its mirror at the pre-rename path; its saved toggles must
|
|
178
|
+
// keep working rather than silently resetting to "everything on".
|
|
179
|
+
const legacyOnly = tmpPrefs({ sounds: { ship: false }, volume: 40 });
|
|
180
|
+
// readPrefs falls back only when its FIRST path is unreadable, so point it at
|
|
181
|
+
// an absent file and assert the fallback chain does not throw or lose the shape.
|
|
182
|
+
const p = player.readPrefs(path.join(os.tmpdir(), 'otb-absent-canonical.json'));
|
|
183
|
+
assert.equal(typeof p.volume, 'number');
|
|
184
|
+
assert.equal(typeof p.enabled('ship'), 'boolean');
|
|
185
|
+
// And the explicit-path form still reads exactly what it is given.
|
|
186
|
+
const direct = player.readPrefs(legacyOnly);
|
|
187
|
+
assert.equal(direct.enabled('ship'), false);
|
|
188
|
+
assert.equal(direct.volume, 40);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the Stop hook resolves the builder CHOSEN variant, not just the fallback', () => {
|
|
192
|
+
// The hook exists at all only as of this task — .claude/settings.json had no
|
|
193
|
+
// Stop hook, so the "Claude finished a turn" sound had nothing to fire it.
|
|
194
|
+
const turnEnd = require(path.join(ROOT, 'scripts', 'gds', 'turn-end-sound.js'));
|
|
195
|
+
assert.equal(turnEnd.EVENT_ID, 'user-input');
|
|
196
|
+
assert.equal(turnEnd.FALLBACK_WAV, 'chime');
|
|
197
|
+
const wav = turnEnd.resolveWav();
|
|
198
|
+
assert.equal(typeof wav, 'string');
|
|
199
|
+
assert.ok(fs.existsSync(path.join(WAV_DIR, `${wav}.wav`)),
|
|
200
|
+
`the hook resolved ${wav}, which has no file`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the Stop hook and the prefs mirror are actually WIRED into settings.json', () => {
|
|
204
|
+
// fetch-sound-prefs.js described "a LOCAL Stop hook" for months while
|
|
205
|
+
// .claude/settings.json contained no Stop hook and never invoked the mirror.
|
|
206
|
+
// Documentation is not a trigger.
|
|
207
|
+
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
|
|
208
|
+
const cmds = (key) => (settings.hooks?.[key] || []).flatMap((g) => (g.hooks || []).map((h) => h.command));
|
|
209
|
+
assert.ok(cmds('Stop').some((c) => c.includes('turn-end-sound')), 'no Stop hook rings the turn-end sound');
|
|
210
|
+
assert.ok(cmds('SessionStart').some((c) => c.includes('fetch-sound-prefs')),
|
|
211
|
+
'nothing mirrors the prefs, so the toggles cannot take local effect');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── path safety: the wav name comes off disk, so it is input ────────────────
|
|
215
|
+
|
|
216
|
+
test('a wav name is a bare stem — traversal and absolute paths are refused', () => {
|
|
217
|
+
for (const bad of ['../../etc/passwd', '/etc/passwd', '..', '', 'a/b', 'sh ell', null, undefined, 42, 'x'.repeat(65)]) {
|
|
218
|
+
assert.equal(player.safeName(bad), null, `${JSON.stringify(bad)} must not be accepted`);
|
|
219
|
+
}
|
|
220
|
+
for (const good of ['ship', 'chime', 'bronze-fanfare', 'Alert_2']) {
|
|
221
|
+
assert.equal(player.safeName(good), good);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('playSound refuses a bad name without touching the filesystem', () => {
|
|
226
|
+
let spawned = 0;
|
|
227
|
+
const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
|
|
228
|
+
assert.equal(player.playSound('../../etc/passwd', { spawnFn }), false);
|
|
229
|
+
assert.equal(player.playSound('nope-not-a-sound', { spawnFn }), false);
|
|
230
|
+
assert.equal(spawned, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── prefs: fail OPEN, because being mutely broken is the bug ────────────────
|
|
234
|
+
|
|
235
|
+
test('a missing prefs file plays everything (fail open)', () => {
|
|
236
|
+
const p = player.readPrefs(path.join(os.tmpdir(), 'otb-definitely-absent-prefs.json'));
|
|
237
|
+
assert.equal(p.volume, 100);
|
|
238
|
+
assert.equal(p.enabled('ship'), true);
|
|
239
|
+
assert.equal(p.enabled('anything-at-all'), true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('garbage in the prefs file also plays everything', () => {
|
|
243
|
+
const f = path.join(os.tmpdir(), `otb-test-prefs-garbage-${process.pid}.json`);
|
|
244
|
+
fs.writeFileSync(f, 'not json at all');
|
|
245
|
+
const p = player.readPrefs(f);
|
|
246
|
+
assert.equal(p.volume, 100);
|
|
247
|
+
assert.equal(p.enabled('ship'), true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('only an EXPLICIT false mutes a sound', () => {
|
|
251
|
+
const p = player.readPrefs(tmpPrefs({ sounds: { ship: false, alert: true }, volume: 70 }));
|
|
252
|
+
assert.equal(p.enabled('ship'), false);
|
|
253
|
+
assert.equal(p.enabled('alert'), true);
|
|
254
|
+
assert.equal(p.enabled('triumph'), true, 'a sound absent from the map is not muted');
|
|
255
|
+
assert.equal(p.volume, 70);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('the LEGACY flat prefs shape is still read', () => {
|
|
259
|
+
// fetch-sound-prefs.js documents that play.js must tolerate the old
|
|
260
|
+
// { <sound>: <bool> } file, which older boxes still have on disk.
|
|
261
|
+
const p = player.readPrefs(tmpPrefs({ ship: false, chime: true }));
|
|
262
|
+
assert.equal(p.enabled('ship'), false);
|
|
263
|
+
assert.equal(p.enabled('chime'), true);
|
|
264
|
+
assert.equal(p.volume, 100, 'no volume key in the legacy shape → full');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('volume is clamped and rounded into 0-100', () => {
|
|
268
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 250 })).volume, 100);
|
|
269
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: -30 })).volume, 0);
|
|
270
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 62.6 })).volume, 63);
|
|
271
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: 'loud' })).volume, 100);
|
|
272
|
+
assert.equal(player.readPrefs(tmpPrefs({ volume: NaN })).volume, 100);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('a muted sound and volume 0 both spawn NOTHING', () => {
|
|
276
|
+
let spawned = 0;
|
|
277
|
+
const spawnFn = () => { spawned += 1; return { on() {}, unref() {} }; };
|
|
278
|
+
assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: { ship: false }, volume: 100 }), spawnFn }), false);
|
|
279
|
+
assert.equal(player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 0 }), spawnFn }), false);
|
|
280
|
+
assert.equal(spawned, 0, 'silence must not cost a process');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── the volume scaler (aplay and PowerShell have no volume flag) ────────────
|
|
284
|
+
|
|
285
|
+
test('scaleWav halves the samples and preserves the file length', () => {
|
|
286
|
+
const src = wav([0, 1000, -2000, 32000, -32000]);
|
|
287
|
+
const out = player.scaleWav(src, 0.5);
|
|
288
|
+
assert.equal(out.length, src.length);
|
|
289
|
+
const at = (b, i) => b.readInt16LE(b.length - 10 + i * 2);
|
|
290
|
+
assert.equal(at(out, 1), 500);
|
|
291
|
+
assert.equal(at(out, 2), -1000);
|
|
292
|
+
assert.equal(at(out, 3), 16000);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('scaleWav clamps rather than wrapping — a gain above 1 must not invert audio', () => {
|
|
296
|
+
const out = player.scaleWav(wav([32000, -32000]), 2);
|
|
297
|
+
assert.equal(out.readInt16LE(out.length - 4), 32767);
|
|
298
|
+
assert.equal(out.readInt16LE(out.length - 2), -32768);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('scaleWav walks the chunk list instead of assuming data starts at byte 44', () => {
|
|
302
|
+
// A WAV carrying a LIST chunk before `data` would be corrupted by a naive
|
|
303
|
+
// fixed-offset scaler — it would scale the metadata and skip the audio.
|
|
304
|
+
const src = wav([1000, 2000], { extraChunk: true });
|
|
305
|
+
const out = player.scaleWav(src, 0.5);
|
|
306
|
+
assert.equal(out.readInt16LE(out.length - 4), 500);
|
|
307
|
+
assert.equal(out.readInt16LE(out.length - 2), 1000);
|
|
308
|
+
assert.equal(out.toString('ascii', 36, 40), 'LIST', 'the metadata chunk is left intact');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('scaleWav leaves anything it does not understand completely alone', () => {
|
|
312
|
+
const notWav = Buffer.from('this is not audio at all');
|
|
313
|
+
assert.ok(player.scaleWav(notWav, 0.5).equals(notWav));
|
|
314
|
+
assert.ok(player.scaleWav(Buffer.alloc(10), 0.5).equals(Buffer.alloc(10)));
|
|
315
|
+
const eightBit = wav([1000, 2000], { bits: 8 });
|
|
316
|
+
assert.ok(player.scaleWav(eightBit, 0.5).equals(eightBit), '8-bit is passed through, not mangled');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── the spawn, exercised without real audio ────────────────────────────────
|
|
320
|
+
|
|
321
|
+
test('a playable sound spawns the platform player, detached and unref\'d', () => {
|
|
322
|
+
const calls = [];
|
|
323
|
+
const spawnFn = (cmd, args, opts) => { calls.push({ cmd, args, opts }); return { on() {}, unref() {} }; };
|
|
324
|
+
// playerFor returns null on a machine with no audio binary (CI containers), in
|
|
325
|
+
// which case there is nothing to assert about the spawn — so this test asserts
|
|
326
|
+
// the CONTRACT that holds either way: it never throws, and it only ever spawns
|
|
327
|
+
// when a player exists.
|
|
328
|
+
const res = player.playSound('ship', { prefsPath: tmpPrefs({ sounds: {}, volume: 100 }), spawnFn });
|
|
329
|
+
const available = player.playerFor(path.join(WAV_DIR, 'ship.wav')) !== null;
|
|
330
|
+
assert.equal(res, available, 'plays exactly when this machine has a player');
|
|
331
|
+
assert.equal(calls.length, available ? 1 : 0);
|
|
332
|
+
if (available) {
|
|
333
|
+
assert.equal(calls[0].opts.detached, true, 'audio must not hold the caller open');
|
|
334
|
+
assert.equal(calls[0].opts.stdio, 'ignore');
|
|
335
|
+
assert.ok(calls[0].args.some((a) => String(a).includes('ship')), 'the chosen wav reaches the player');
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('playerFor never throws and returns null or a [cmd, args] pair', () => {
|
|
340
|
+
const got = player.playerFor('/tmp/whatever.wav');
|
|
341
|
+
if (got !== null) {
|
|
342
|
+
assert.equal(typeof got[0], 'string');
|
|
343
|
+
assert.ok(Array.isArray(got[1]));
|
|
344
|
+
}
|
|
345
|
+
});
|