@henryz2004/agency 1.0.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.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
package/public/sound.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// sound.js — two INDEPENDENT audio channels for the office:
|
|
2
|
+
// 1. SFX — synthesized "busy office" keyboard clatter via the Web Audio API.
|
|
3
|
+
// Zero asset files: each keystroke is a short filtered noise burst. The
|
|
4
|
+
// clatter rate/intensity scales with how many agents are generating;
|
|
5
|
+
// fully silent when nobody is working.
|
|
6
|
+
// 2. MUSIC — a shuffled, endless lo-fi playlist (real mp3s under public/music/),
|
|
7
|
+
// played with a plain HTMLAudioElement (no AudioContext).
|
|
8
|
+
//
|
|
9
|
+
// The two channels enable/disable, persist, and start/stop ENTIRELY separately:
|
|
10
|
+
// music-on/SFX-off and SFX-on/music-off are both valid. Each has its own toggle
|
|
11
|
+
// function and its own localStorage key.
|
|
12
|
+
//
|
|
13
|
+
// BROWSER RULES: the AudioContext is created/resumed ONLY after a user gesture
|
|
14
|
+
// (a toggle). Music play() is likewise only kicked off from a gesture. Nothing
|
|
15
|
+
// autoplays; everything fails soft; prefs persist in localStorage and default OFF.
|
|
16
|
+
|
|
17
|
+
// New per-channel pref keys. We migrate the OLD combined key (`agency.sound`)
|
|
18
|
+
// forward: if a returning user had the single combined toggle ON, both channels
|
|
19
|
+
// default ON so they aren't surprised by the split.
|
|
20
|
+
const SFX_KEY = 'agency.sfx';
|
|
21
|
+
const MUSIC_KEY = 'agency.music';
|
|
22
|
+
const LEGACY_KEY = 'agency.sound';
|
|
23
|
+
|
|
24
|
+
// --- SFX (Web Audio keyboard clatter) ---------------------------------------
|
|
25
|
+
|
|
26
|
+
let ctx = null; // AudioContext, created lazily on first SFX enable
|
|
27
|
+
let master = null; // master gain -> destination
|
|
28
|
+
let noiseBuffer = null; // shared white-noise buffer for key clicks
|
|
29
|
+
|
|
30
|
+
let sfxEnabled = false; // whether the clatter engine is actively running
|
|
31
|
+
let workingCount = 0; // agents currently generating
|
|
32
|
+
let scheduler = null; // setInterval handle for the clatter loop
|
|
33
|
+
let nextKeyAt = 0; // ctx.currentTime of the next scheduled keystroke
|
|
34
|
+
|
|
35
|
+
// Keep the master volume LOW and tasteful.
|
|
36
|
+
const MASTER_VOL = 0.18;
|
|
37
|
+
|
|
38
|
+
// --- ambient music (real lo-fi tracks) --------------------------------------
|
|
39
|
+
// Four mastered lo-fi tracks under public/music/, played as a shuffled, endless
|
|
40
|
+
// playlist whenever MUSIC is on. Uses a plain HTMLAudioElement (NOT Web Audio) so
|
|
41
|
+
// it needs no AudioContext and fails soft on its own; the volume is ramped by hand
|
|
42
|
+
// for a gentle fade in/out. Started/stopped from the music toggle — a user gesture,
|
|
43
|
+
// so the browser autoplay policy is satisfied.
|
|
44
|
+
const MUSIC_VOL = 0.5; // comfortable background level for the recordings
|
|
45
|
+
const MUSIC_FADE_MS = 1800; // fade-in (a quicker fade-out on stop) so it never jars
|
|
46
|
+
const TRACKS = [
|
|
47
|
+
'/music/Midnight_Desk.mp3',
|
|
48
|
+
'/music/The_Plant_Beside_the_Door.mp3',
|
|
49
|
+
'/music/Three_AM_Window.mp3',
|
|
50
|
+
'/music/Console_Morning.mp3',
|
|
51
|
+
];
|
|
52
|
+
let musicEnabled = false; // whether the playlist channel is on
|
|
53
|
+
let musicEl = null; // current HTMLAudioElement, or null when off
|
|
54
|
+
let musicQueue = []; // shuffled play order (indices into TRACKS)
|
|
55
|
+
let musicPos = 0; // position within musicQueue
|
|
56
|
+
let musicMisses = 0; // consecutive load failures → give up if all unreachable
|
|
57
|
+
let currentTrack = ''; // pretty name of the track currently sounding (or '')
|
|
58
|
+
|
|
59
|
+
// Track-change subscribers. We BOTH dispatch a window CustomEvent and call any
|
|
60
|
+
// registered callbacks, so consumers can pick whichever is simpler.
|
|
61
|
+
const trackListeners = new Set();
|
|
62
|
+
const TRACK_EVENT = 'agency:track';
|
|
63
|
+
|
|
64
|
+
// --- pref read/write (per channel, fail-soft) -------------------------------
|
|
65
|
+
|
|
66
|
+
function readKey(key) {
|
|
67
|
+
try {
|
|
68
|
+
return localStorage.getItem(key);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function writeKey(key, on) {
|
|
74
|
+
try {
|
|
75
|
+
localStorage.setItem(key, on ? 'on' : 'off');
|
|
76
|
+
} catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve the persisted pref for a channel, applying the one-time legacy
|
|
82
|
+
// migration: if the new key was never written but the old combined key was ON,
|
|
83
|
+
// treat this channel as ON.
|
|
84
|
+
function loadChannelPref(key) {
|
|
85
|
+
const v = readKey(key);
|
|
86
|
+
if (v === 'on') return true;
|
|
87
|
+
if (v === 'off') return false;
|
|
88
|
+
// No new-key value yet → fall back to the legacy combined pref.
|
|
89
|
+
return readKey(LEGACY_KEY) === 'on';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- SFX engine -------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
// Build a small white-noise buffer once; reused for every keystroke.
|
|
95
|
+
function makeNoiseBuffer(ac) {
|
|
96
|
+
const len = Math.floor(ac.sampleRate * 0.12); // ~120ms is plenty per click
|
|
97
|
+
const buf = ac.createBuffer(1, len, ac.sampleRate);
|
|
98
|
+
const data = buf.getChannelData(0);
|
|
99
|
+
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
|
100
|
+
return buf;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Create the audio graph. Must run inside a user gesture.
|
|
104
|
+
function ensureContext() {
|
|
105
|
+
if (ctx) return;
|
|
106
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
107
|
+
if (!AC) return;
|
|
108
|
+
ctx = new AC();
|
|
109
|
+
noiseBuffer = makeNoiseBuffer(ctx);
|
|
110
|
+
master = ctx.createGain();
|
|
111
|
+
master.gain.value = MASTER_VOL;
|
|
112
|
+
// A gentle low-pass over the whole bus keeps it soft, not hissy.
|
|
113
|
+
const lp = ctx.createBiquadFilter();
|
|
114
|
+
lp.type = 'lowpass';
|
|
115
|
+
lp.frequency.value = 5200;
|
|
116
|
+
lp.Q.value = 0.3;
|
|
117
|
+
master.connect(lp);
|
|
118
|
+
lp.connect(ctx.destination);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One synthesized key click: a short filtered noise burst with a fast decay.
|
|
122
|
+
function playKey(time, gain) {
|
|
123
|
+
if (!ctx || !noiseBuffer) return;
|
|
124
|
+
const src = ctx.createBufferSource();
|
|
125
|
+
src.buffer = noiseBuffer;
|
|
126
|
+
|
|
127
|
+
// Band-pass-ish shaping so each click sounds like a key, not static.
|
|
128
|
+
const bp = ctx.createBiquadFilter();
|
|
129
|
+
bp.type = 'bandpass';
|
|
130
|
+
bp.frequency.value = 1400 + Math.random() * 2200; // vary pitch per key
|
|
131
|
+
bp.Q.value = 0.7 + Math.random() * 0.8;
|
|
132
|
+
|
|
133
|
+
const env = ctx.createGain();
|
|
134
|
+
const peak = gain * (0.6 + Math.random() * 0.5);
|
|
135
|
+
const dur = 0.018 + Math.random() * 0.03; // 18–48ms clicks
|
|
136
|
+
env.gain.setValueAtTime(0.0001, time);
|
|
137
|
+
env.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), time + 0.002);
|
|
138
|
+
env.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
|
139
|
+
|
|
140
|
+
src.connect(bp);
|
|
141
|
+
bp.connect(env);
|
|
142
|
+
env.connect(master);
|
|
143
|
+
src.start(time);
|
|
144
|
+
src.stop(time + dur + 0.01);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Schedule keystrokes a little ahead of the clock for smooth timing. Called on
|
|
148
|
+
// an interval; it fills the lookahead window with clicks whose density tracks
|
|
149
|
+
// the working-agent count.
|
|
150
|
+
const LOOKAHEAD = 0.18; // seconds to schedule ahead
|
|
151
|
+
function tickScheduler() {
|
|
152
|
+
if (!ctx || !sfxEnabled || workingCount <= 0) return;
|
|
153
|
+
const now = ctx.currentTime;
|
|
154
|
+
if (nextKeyAt < now) nextKeyAt = now;
|
|
155
|
+
|
|
156
|
+
// Keys/sec scales with working agents: a brisk-but-not-frantic typist each.
|
|
157
|
+
// ~7 keys/sec per working agent, with a soft cap so a big team isn't a roar.
|
|
158
|
+
const kps = Math.min(38, 6.5 * workingCount + 1.5);
|
|
159
|
+
// Slightly lower per-key gain as the floor gets busier (overlap sums up).
|
|
160
|
+
const perKeyGain = Math.max(0.35, 1 / (1 + workingCount * 0.45));
|
|
161
|
+
|
|
162
|
+
while (nextKeyAt < now + LOOKAHEAD) {
|
|
163
|
+
// Humanize: exponential-ish gaps around the mean interval.
|
|
164
|
+
const meanGap = 1 / kps;
|
|
165
|
+
const gap = meanGap * (0.45 + Math.random() * 1.3);
|
|
166
|
+
playKey(nextKeyAt, perKeyGain);
|
|
167
|
+
nextKeyAt += gap;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function startScheduler() {
|
|
172
|
+
if (scheduler != null) return;
|
|
173
|
+
scheduler = setInterval(tickScheduler, 60);
|
|
174
|
+
}
|
|
175
|
+
function stopScheduler() {
|
|
176
|
+
if (scheduler != null) {
|
|
177
|
+
clearInterval(scheduler);
|
|
178
|
+
scheduler = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- shuffled-playlist player ----------------------------------------------
|
|
183
|
+
|
|
184
|
+
// Fisher–Yates shuffle of a copy (Math.random is fine in the browser).
|
|
185
|
+
function shuffled(arr) {
|
|
186
|
+
const a = arr.slice();
|
|
187
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
188
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
189
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
190
|
+
}
|
|
191
|
+
return a;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Turn '/music/The_Plant_Beside_the_Door.mp3' → 'The Plant Beside the Door'.
|
|
195
|
+
function prettyTrack(path) {
|
|
196
|
+
try {
|
|
197
|
+
const file = path.split('/').pop() || path;
|
|
198
|
+
return file.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
|
199
|
+
} catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Record the now-playing track name and notify subscribers (event + callbacks).
|
|
205
|
+
function setCurrentTrack(name) {
|
|
206
|
+
currentTrack = name || '';
|
|
207
|
+
try {
|
|
208
|
+
window.dispatchEvent(new CustomEvent(TRACK_EVENT, { detail: { track: currentTrack } }));
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore — no window or CustomEvent */
|
|
211
|
+
}
|
|
212
|
+
trackListeners.forEach((fn) => {
|
|
213
|
+
try { fn(currentTrack); } catch { /* a bad listener must not break playback */ }
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Linearly ramp el.volume to `target` over `ms`, then run `done`. Tracks its own
|
|
218
|
+
// interval on el._fadeId so a teardown can cancel an in-flight fade.
|
|
219
|
+
function ramp(el, target, ms, done) {
|
|
220
|
+
const start = el.volume;
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
const id = setInterval(() => {
|
|
223
|
+
const k = ms <= 0 ? 1 : Math.min(1, (Date.now() - t0) / ms);
|
|
224
|
+
el.volume = Math.max(0, Math.min(1, start + (target - start) * k));
|
|
225
|
+
if (k >= 1) {
|
|
226
|
+
clearInterval(id);
|
|
227
|
+
if (el._fadeId === id) el._fadeId = 0;
|
|
228
|
+
if (done) done();
|
|
229
|
+
}
|
|
230
|
+
}, 40);
|
|
231
|
+
el._fadeId = id;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Pause + release an element, cancelling any fade still running on it.
|
|
235
|
+
function killEl(el) {
|
|
236
|
+
if (!el) return;
|
|
237
|
+
if (el._fadeId) { clearInterval(el._fadeId); el._fadeId = 0; }
|
|
238
|
+
try { el.pause(); el.removeAttribute('src'); el.load(); } catch { /* fail-soft */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Start the playlist: shuffle, then play the first track. Needs no AudioContext;
|
|
242
|
+
// called from toggleMusic (a user gesture) so play() is allowed. Fail-soft.
|
|
243
|
+
function startMusic() {
|
|
244
|
+
if (musicEl) return;
|
|
245
|
+
try {
|
|
246
|
+
musicQueue = shuffled(TRACKS.map((_, i) => i));
|
|
247
|
+
musicPos = 0;
|
|
248
|
+
musicMisses = 0;
|
|
249
|
+
playCurrent();
|
|
250
|
+
} catch {
|
|
251
|
+
musicEl = null; // fail-soft
|
|
252
|
+
setCurrentTrack('');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Load + fade in the current queue track; auto-advance on end, skip on error.
|
|
257
|
+
function playCurrent() {
|
|
258
|
+
const path = TRACKS[musicQueue[musicPos]];
|
|
259
|
+
const el = new Audio(path);
|
|
260
|
+
el.preload = 'auto';
|
|
261
|
+
el.volume = 0.0001;
|
|
262
|
+
el._fadeId = 0;
|
|
263
|
+
musicEl = el;
|
|
264
|
+
// Announce the name as soon as it actually starts sounding (and reset misses).
|
|
265
|
+
el.addEventListener('playing', () => {
|
|
266
|
+
if (musicEl !== el) return;
|
|
267
|
+
musicMisses = 0;
|
|
268
|
+
setCurrentTrack(prettyTrack(path));
|
|
269
|
+
});
|
|
270
|
+
el.addEventListener('ended', () => { if (musicEnabled && musicEl === el) advanceTrack(); });
|
|
271
|
+
el.addEventListener('error', () => {
|
|
272
|
+
if (!musicEnabled || musicEl !== el) return;
|
|
273
|
+
musicMisses++;
|
|
274
|
+
if (musicMisses >= TRACKS.length) { stopMusic(); return; } // all unreachable → give up
|
|
275
|
+
advanceTrack();
|
|
276
|
+
});
|
|
277
|
+
const p = el.play();
|
|
278
|
+
if (p && p.catch) p.catch(() => { /* autoplay blocked / decode fail — fail-soft */ });
|
|
279
|
+
ramp(el, MUSIC_VOL, MUSIC_FADE_MS); // gentle fade-in
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Move to the next track, reshuffling each time the set completes so the order
|
|
283
|
+
// varies. Tears down the finished element first.
|
|
284
|
+
function advanceTrack() {
|
|
285
|
+
const old = musicEl;
|
|
286
|
+
musicEl = null;
|
|
287
|
+
killEl(old);
|
|
288
|
+
musicPos++;
|
|
289
|
+
if (musicPos >= musicQueue.length) {
|
|
290
|
+
musicQueue = shuffled(TRACKS.map((_, i) => i));
|
|
291
|
+
musicPos = 0;
|
|
292
|
+
}
|
|
293
|
+
playCurrent();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Fade out + release the current track. Detaches musicEl immediately so a quick
|
|
297
|
+
// re-enable builds a fresh element without the old one's fade fighting it (the
|
|
298
|
+
// fade-out runs on the detached element's own _fadeId and pauses it when done).
|
|
299
|
+
function stopMusic() {
|
|
300
|
+
const el = musicEl;
|
|
301
|
+
setCurrentTrack(''); // clear the now-playing label immediately
|
|
302
|
+
if (!el) return;
|
|
303
|
+
musicEl = null;
|
|
304
|
+
if (el._fadeId) { clearInterval(el._fadeId); el._fadeId = 0; }
|
|
305
|
+
ramp(el, 0.0001, 400, () => killEl(el));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Suspend the AudioContext to save CPU when SFX is off — deferred so the clatter
|
|
309
|
+
// tail finishes, and guarded so a quick re-enable doesn't get muted.
|
|
310
|
+
function maybeSuspendContext() {
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
if (!sfxEnabled && ctx && ctx.state === 'running') ctx.suspend();
|
|
313
|
+
}, 450);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- public API ------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export function isSfxEnabled() {
|
|
319
|
+
return sfxEnabled;
|
|
320
|
+
}
|
|
321
|
+
export function isMusicEnabled() {
|
|
322
|
+
return musicEnabled;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Pretty name of the track currently sounding, or '' when music is off.
|
|
326
|
+
export function currentTrackName() {
|
|
327
|
+
return currentTrack;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Subscribe to track changes. Returns an unsubscribe function. The callback is
|
|
331
|
+
// invoked with the pretty track name (or '' when music stops). Consumers may
|
|
332
|
+
// instead listen for the `agency:track` CustomEvent on window — both fire.
|
|
333
|
+
export function onTrackChange(fn) {
|
|
334
|
+
if (typeof fn !== 'function') return () => {};
|
|
335
|
+
trackListeners.add(fn);
|
|
336
|
+
return () => trackListeners.delete(fn);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Read the persisted prefs WITHOUT creating any audio or starting anything
|
|
340
|
+
// (safe pre-gesture). Returns the desired pre-gesture button state for both
|
|
341
|
+
// channels; the engines stay off until the first user gesture toggles them.
|
|
342
|
+
// The actual start happens in toggleSfx/toggleMusic.
|
|
343
|
+
export function initSoundPref() {
|
|
344
|
+
return {
|
|
345
|
+
sfx: loadChannelPref(SFX_KEY),
|
|
346
|
+
music: loadChannelPref(MUSIC_KEY),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Toggle the SFX (keyboard clatter) channel. MUST be called from a user gesture
|
|
351
|
+
// handler. Creates/resumes the AudioContext on first enable. Returns new state.
|
|
352
|
+
export function toggleSfx() {
|
|
353
|
+
sfxEnabled = !sfxEnabled;
|
|
354
|
+
writeKey(SFX_KEY, sfxEnabled);
|
|
355
|
+
if (sfxEnabled) {
|
|
356
|
+
ensureContext();
|
|
357
|
+
if (ctx && ctx.state === 'suspended') ctx.resume();
|
|
358
|
+
startScheduler();
|
|
359
|
+
} else {
|
|
360
|
+
stopScheduler();
|
|
361
|
+
maybeSuspendContext();
|
|
362
|
+
}
|
|
363
|
+
return sfxEnabled;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Toggle the MUSIC (lo-fi playlist) channel. MUST be called from a user gesture
|
|
367
|
+
// handler so play() is allowed. Returns the new enabled state.
|
|
368
|
+
export function toggleMusic() {
|
|
369
|
+
musicEnabled = !musicEnabled;
|
|
370
|
+
writeKey(MUSIC_KEY, musicEnabled);
|
|
371
|
+
if (musicEnabled) {
|
|
372
|
+
startMusic();
|
|
373
|
+
} else {
|
|
374
|
+
stopMusic();
|
|
375
|
+
}
|
|
376
|
+
return musicEnabled;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Called each poll/frame with the current count of working agents.
|
|
380
|
+
export function updateSound(count) {
|
|
381
|
+
workingCount = Math.max(0, count | 0);
|
|
382
|
+
}
|