dsh-theme-endfield 1.1.9 → 1.2.3
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 +163 -118
- package/client.js +10186 -10063
- package/docs/PR-description.md +141 -141
- package/docs/audio-notifications.md +133 -133
- package/docs/contour-scan-bounds.md +24 -24
- package/docs/contour-trail.md +61 -61
- package/docs/contour-worker.md +38 -38
- package/docs/design-language.md +212 -212
- package/docs/engineering-notes.md +1138 -1147
- package/docs/features.md +298 -295
- package/docs/glass.md +32 -32
- package/docs/testing.md +427 -427
- package/index.js +1403 -1403
- package/lib/audio.js +426 -426
- package/lib/slots.js +97 -97
- package/lib/tone.js +216 -216
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +113 -105
package/lib/audio.js
CHANGED
|
@@ -1,426 +1,426 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/**
|
|
3
|
-
* Audio notification engine for the installed (bundle) HOST half.
|
|
4
|
-
*
|
|
5
|
-
* Why the sound is played by the HOST and not in the browser: the moments worth
|
|
6
|
-
* marking are exactly the moments the user is NOT looking at the page. A
|
|
7
|
-
* browser `AudioContext` is suspended until a user gesture and dies with the
|
|
8
|
-
* tab, so a task that finishes while the window is minimized would stay silent.
|
|
9
|
-
* The host owns a real process boundary instead and hands the file to the
|
|
10
|
-
* platform player.
|
|
11
|
-
*
|
|
12
|
-
* Consequences handled here:
|
|
13
|
-
* - `subprocess` is an optional seam. Without it the whole feature degrades to
|
|
14
|
-
* a no-op (and says so once) rather than throwing during boot.
|
|
15
|
-
* - The player is a child process per play. Playback is therefore
|
|
16
|
-
* "fire and forget", latest-wins per slot, with a hard grace timeout.
|
|
17
|
-
* - Volume is applied by rescaling PCM samples into a cache file, never by
|
|
18
|
-
* touching the system volume.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const fs = require('fs');
|
|
22
|
-
const os = require('os');
|
|
23
|
-
const path = require('path');
|
|
24
|
-
const { scaleWavVolume, parseWav } = require('./tone.js');
|
|
25
|
-
const { SLOT_IDS } = require('./slots.js');
|
|
26
|
-
|
|
27
|
-
const LOG_TAG = '[theme-endfield/audio]';
|
|
28
|
-
const BUNDLED_SOUNDS = path.join(__dirname, '..', 'sounds');
|
|
29
|
-
const CACHE_DIR = path.join(os.tmpdir(), 'dsh-theme-endfield');
|
|
30
|
-
|
|
31
|
-
/** Preference keys owned by this feature (all strings, per the namespace convention). */
|
|
32
|
-
const PREF = {
|
|
33
|
-
enabled: 'audioEnabled',
|
|
34
|
-
volume: 'audioVolume',
|
|
35
|
-
boot: 'audioBoot',
|
|
36
|
-
start: 'audioTurnStart',
|
|
37
|
-
done: 'audioTurnDone',
|
|
38
|
-
attention: 'audioAttention',
|
|
39
|
-
fail: 'audioTurnFail',
|
|
40
|
-
debounceMs: 'audioDebounceMs',
|
|
41
|
-
soundDir: 'audioSoundDir',
|
|
42
|
-
humanOnly: 'audioHumanOnly',
|
|
43
|
-
diag: 'audioDiag',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** Defaults mirroring FIELD_DEFAULTS in index.js — the master switch ships OFF
|
|
47
|
-
* (sound is opt-in); the per-slot switches ship ON, so turning the master on is
|
|
48
|
-
* what starts the sound. */
|
|
49
|
-
const FALLBACK = {
|
|
50
|
-
[PREF.enabled]: '0',
|
|
51
|
-
[PREF.volume]: '100',
|
|
52
|
-
[PREF.boot]: '1',
|
|
53
|
-
[PREF.start]: '1',
|
|
54
|
-
[PREF.done]: '1',
|
|
55
|
-
[PREF.attention]: '1',
|
|
56
|
-
[PREF.fail]: '1',
|
|
57
|
-
[PREF.debounceMs]: '2500',
|
|
58
|
-
[PREF.soundDir]: '',
|
|
59
|
-
[PREF.humanOnly]: '1',
|
|
60
|
-
[PREF.diag]: '0',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
/** The user's Desktop, probed lazily: a convenient drop-in place for own sounds. */
|
|
64
|
-
function desktopDir() {
|
|
65
|
-
const home = typeof os.homedir === 'function' ? os.homedir() : '';
|
|
66
|
-
if (home === '') return undefined;
|
|
67
|
-
for (const candidate of [path.join(home, 'Desktop'), path.join(home, '桌面')]) {
|
|
68
|
-
try {
|
|
69
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
70
|
-
} catch (error) { /* ignore */ }
|
|
71
|
-
}
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Short stable hash of a path for cache-file naming: FNV-1a, hex. */
|
|
76
|
-
function pathHash(value) {
|
|
77
|
-
let h = 0x811c9dc5;
|
|
78
|
-
for (let i = 0; i < value.length; i++) {
|
|
79
|
-
h ^= value.charCodeAt(i);
|
|
80
|
-
h = Math.imul(h, 0x01000193) >>> 0;
|
|
81
|
-
}
|
|
82
|
-
return h.toString(16);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The audio runtime: preference view, sound resolution, volume cache, playback
|
|
87
|
-
* and the diagnostic ring the settings page reads.
|
|
88
|
-
*/
|
|
89
|
-
class AudioRuntime {
|
|
90
|
-
/**
|
|
91
|
-
* @param ctx - the host Cordis context the feature is mounted on.
|
|
92
|
-
*/
|
|
93
|
-
constructor(ctx) {
|
|
94
|
-
this.ctx = ctx;
|
|
95
|
-
this.values = Object.assign({}, FALLBACK);
|
|
96
|
-
this.scope = undefined;
|
|
97
|
-
/** Last play timestamp per slot, for the per-slot debounce window. */
|
|
98
|
-
this.lastPlayed = Object.create(null);
|
|
99
|
-
/** Per slot: the instant its previous announcement finishes speaking. */
|
|
100
|
-
this.playUntil = Object.create(null);
|
|
101
|
-
/** Sound-file duration in ms, memoised per path (read once per file). */
|
|
102
|
-
this.durationCache = new Map();
|
|
103
|
-
/** Slots whose child process has not settled yet — no overlapping play. */
|
|
104
|
-
this.playing = new Set();
|
|
105
|
-
/** Bounded diagnostic ring: what the plugin saw and what it did about it. */
|
|
106
|
-
this.diag = [];
|
|
107
|
-
this.warnedNoSubprocess = false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Record one diagnostic line and echo it to the host console. */
|
|
111
|
-
note(kind, detail) {
|
|
112
|
-
const entry = { at: Date.now(), kind, detail: detail === undefined ? '' : String(detail) };
|
|
113
|
-
this.diag.push(entry);
|
|
114
|
-
if (this.diag.length > 60) this.diag.shift();
|
|
115
|
-
console.log(`${LOG_TAG} ${kind}${entry.detail === '' ? '' : ' — ' + entry.detail}`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Whether verbose diagnostics are on (the settings page can switch them on). */
|
|
119
|
-
get diagnosing() {
|
|
120
|
-
return this.values[PREF.diag] !== '0';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Adopt the durable preference snapshot. */
|
|
124
|
-
setValues(values) {
|
|
125
|
-
this.values = Object.assign({}, FALLBACK, values === undefined ? {} : values);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** One preference as a string, falling back to the shipped default. */
|
|
129
|
-
value(key) {
|
|
130
|
-
const raw = this.values[key];
|
|
131
|
-
return raw === undefined || raw === null ? FALLBACK[key] : String(raw);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** One preference as an integer with bounds. */
|
|
135
|
-
number(key, min, max) {
|
|
136
|
-
const parsed = Number.parseInt(this.value(key), 10);
|
|
137
|
-
if (!Number.isFinite(parsed)) return Number.parseInt(FALLBACK[key], 10);
|
|
138
|
-
return Math.min(max, Math.max(min, parsed));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Whether one switch preference is on (stored `'1'`, read with `!== '0'`). */
|
|
142
|
-
enabled(key) {
|
|
143
|
-
return this.value(key) !== '0';
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Candidate directories for own sound files, most specific first. */
|
|
147
|
-
soundDirs() {
|
|
148
|
-
const dirs = [];
|
|
149
|
-
const configured = this.value(PREF.soundDir);
|
|
150
|
-
if (configured !== '') dirs.push(configured);
|
|
151
|
-
try {
|
|
152
|
-
const workspace = this.ctx.get('workspace');
|
|
153
|
-
const root = workspace === undefined || typeof workspace.root !== 'function' ? undefined : workspace.root();
|
|
154
|
-
if (typeof root === 'string' && root !== '') dirs.push(root);
|
|
155
|
-
} catch (error) { /* workspace service absent — fine */ }
|
|
156
|
-
const desktop = desktopDir();
|
|
157
|
-
if (desktop !== undefined) dirs.push(desktop);
|
|
158
|
-
dirs.push(BUNDLED_SOUNDS);
|
|
159
|
-
return dirs;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Resolve one slot to a playable file: configured dirs, then the workspace,
|
|
164
|
-
* then the Desktop drop-in, then the bundled synthesized tone.
|
|
165
|
-
*
|
|
166
|
-
* @param slot - slot id from `lib/slots.js`.
|
|
167
|
-
* @returns `{ file, origin }` or undefined when nothing exists.
|
|
168
|
-
*/
|
|
169
|
-
resolve(slot) {
|
|
170
|
-
const name = `${slot}.wav`;
|
|
171
|
-
const dirs = this.soundDirs();
|
|
172
|
-
for (const dir of dirs) {
|
|
173
|
-
const candidate = path.join(dir, name);
|
|
174
|
-
try {
|
|
175
|
-
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
176
|
-
return { file: candidate, origin: dir === BUNDLED_SOUNDS ? 'bundled' : candidate };
|
|
177
|
-
}
|
|
178
|
-
} catch (error) { /* unreadable candidate — try the next */ }
|
|
179
|
-
}
|
|
180
|
-
return undefined;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Whether one slot's OWN switch is on.
|
|
185
|
-
*
|
|
186
|
-
* Callers that fire on an event already test this before calling `play()`; it
|
|
187
|
-
* exists as a method so the preview route can honour the same switch and report
|
|
188
|
-
* it, instead of playing a sound the user has switched off. `play()` itself
|
|
189
|
-
* deliberately does NOT consult it: `force` means "ignore the debounce", and
|
|
190
|
-
* folding a second meaning into it would make the switch and the preview fight
|
|
191
|
-
* each other.
|
|
192
|
-
*
|
|
193
|
-
* @param slot - slot id from `lib/slots.js`.
|
|
194
|
-
* @returns true when the slot may sound.
|
|
195
|
-
*/
|
|
196
|
-
slotEnabled(slot) {
|
|
197
|
-
const key = PREF[slotSwitchKey(slot)];
|
|
198
|
-
if (key === undefined) return false;
|
|
199
|
-
return this.enabled(key);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* How long one sound file runs, in milliseconds.
|
|
204
|
-
*
|
|
205
|
-
* Used to derive the per-slot "still speaking" gate. The formula is the WAV
|
|
206
|
-
* one — data bytes / (rate * channels * bytes-per-sample) — and a file that
|
|
207
|
-
* cannot be parsed contributes 0, which degrades to the fixed debounce alone
|
|
208
|
-
* rather than to silence.
|
|
209
|
-
*
|
|
210
|
-
* @param file - sound file path.
|
|
211
|
-
* @returns duration in ms (0 when unknown).
|
|
212
|
-
*/
|
|
213
|
-
soundDurationMs(file) {
|
|
214
|
-
const cached = this.durationCache.get(file);
|
|
215
|
-
if (cached !== undefined) return cached;
|
|
216
|
-
let ms = 0;
|
|
217
|
-
try {
|
|
218
|
-
const parsed = parseWav(fs.readFileSync(file));
|
|
219
|
-
if (parsed !== undefined && parsed.sampleRate > 0 && parsed.channels > 0 && parsed.bitsPerSample > 0) {
|
|
220
|
-
ms = Math.round((parsed.dataSize / (parsed.sampleRate * parsed.channels * (parsed.bitsPerSample / 8))) * 1000);
|
|
221
|
-
}
|
|
222
|
-
} catch (error) {
|
|
223
|
-
ms = 0;
|
|
224
|
-
}
|
|
225
|
-
this.durationCache.set(file, ms);
|
|
226
|
-
return ms;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Apply the volume to a sound file by rescaling its PCM into the cache, so the
|
|
231
|
-
* system volume is never touched.
|
|
232
|
-
*
|
|
233
|
-
* @param file - source wav path.
|
|
234
|
-
* @param volume - 0..100.
|
|
235
|
-
* @returns the file to play (the source itself when scaling is a no-op).
|
|
236
|
-
*/
|
|
237
|
-
applyVolume(file, volume) {
|
|
238
|
-
if (volume >= 100) return file;
|
|
239
|
-
let bytes;
|
|
240
|
-
try {
|
|
241
|
-
bytes = fs.readFileSync(file);
|
|
242
|
-
} catch (error) {
|
|
243
|
-
return file;
|
|
244
|
-
}
|
|
245
|
-
let scaled;
|
|
246
|
-
try {
|
|
247
|
-
scaled = scaleWavVolume(bytes, volume);
|
|
248
|
-
} catch (error) {
|
|
249
|
-
// A malformed user file must degrade to "play it as-is", never escape:
|
|
250
|
-
// play() runs inside the approval / user-questions waterfall callbacks,
|
|
251
|
-
// where a throw would skip next() and break the whole chain (see the
|
|
252
|
-
// tools/execute note in index.js).
|
|
253
|
-
return file;
|
|
254
|
-
}
|
|
255
|
-
if (scaled === bytes) return file; // non-rescalable format: original level
|
|
256
|
-
try {
|
|
257
|
-
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
258
|
-
/* The cache key covers the SOURCE DIRECTORY too: basename + size alone
|
|
259
|
-
would let a turn-done.wav from the custom dir collide with an unrelated
|
|
260
|
-
same-named, same-length file from the desktop, and the second user
|
|
261
|
-
would hear the first one's sound. */
|
|
262
|
-
const target = path.join(CACHE_DIR, `${path.basename(file, path.extname(file))}-v${volume}-${bytes.length}-${pathHash(file)}.wav`);
|
|
263
|
-
if (!fs.existsSync(target)) fs.writeFileSync(target, scaled);
|
|
264
|
-
return target;
|
|
265
|
-
} catch (error) {
|
|
266
|
-
return file;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** The platform command matrix: try each entry until one runs. */
|
|
271
|
-
playerCommands(file) {
|
|
272
|
-
if (process.platform === 'win32') {
|
|
273
|
-
// PowerShell's Media.SoundPlayer takes a literal path, so quote-escape it
|
|
274
|
-
// and hand the whole script over as -EncodedCommand (no shell quoting
|
|
275
|
-
// games, no console window when run detached).
|
|
276
|
-
const escaped = file.replace(/'/g, "''");
|
|
277
|
-
const script = `$p = New-Object Media.SoundPlayer '${escaped}'; $p.PlaySync()`;
|
|
278
|
-
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
279
|
-
const args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded];
|
|
280
|
-
return [
|
|
281
|
-
[path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ...args],
|
|
282
|
-
['pwsh.exe', ...args],
|
|
283
|
-
];
|
|
284
|
-
}
|
|
285
|
-
if (process.platform === 'darwin') return [['/usr/bin/afplay', file]];
|
|
286
|
-
return [['paplay', file], ['aplay', file]];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Play one slot.
|
|
291
|
-
*
|
|
292
|
-
* @param slot - slot id.
|
|
293
|
-
* @param options - `force` skips the debounce (used by the settings preview),
|
|
294
|
-
* `reason` is recorded in diagnostics.
|
|
295
|
-
* @returns the outcome, for the preview route to report.
|
|
296
|
-
*/
|
|
297
|
-
play(slot, options) {
|
|
298
|
-
const force = options !== undefined && options.force === true;
|
|
299
|
-
const reason = options === undefined || options.reason === undefined ? '' : options.reason;
|
|
300
|
-
if (!SLOT_IDS.includes(slot)) return { played: false, why: `unknown slot "${slot}"` };
|
|
301
|
-
if (!this.enabled(PREF.enabled)) return { played: false, why: 'audio disabled' };
|
|
302
|
-
const volume = this.number(PREF.volume, 0, 100);
|
|
303
|
-
if (volume <= 0) return { played: false, why: 'volume is 0' };
|
|
304
|
-
const resolved = this.resolve(slot);
|
|
305
|
-
if (resolved === undefined) return { played: false, why: 'no sound file found' };
|
|
306
|
-
const debounce = this.number(PREF.debounceMs, 0, 60000);
|
|
307
|
-
const now = Date.now();
|
|
308
|
-
if (!force && now - (this.lastPlayed[slot] || 0) < debounce) {
|
|
309
|
-
return { played: false, why: `debounced (${debounce} ms window)`, file: resolved.file };
|
|
310
|
-
}
|
|
311
|
-
/* Second, independent gate: never start a slot while its own previous
|
|
312
|
-
announcement is still speaking.
|
|
313
|
-
|
|
314
|
-
The fixed window above is a floor for rapid repeats, but a real voice line
|
|
315
|
-
is not a chime: the generated Endfield lines run 3-5 seconds (measured), so
|
|
316
|
-
a 2.5 s window would let one line start on top of another. Deriving a
|
|
317
|
-
per-slot window from the file's own duration means a long announcement is
|
|
318
|
-
never clipped and a short chime is not held back — no per-slot setting to
|
|
319
|
-
keep in sync with whatever audio the user drops in. */
|
|
320
|
-
const playingUntil = this.playUntil[slot] || 0;
|
|
321
|
-
if (!force && now < playingUntil) {
|
|
322
|
-
const remaining = playingUntil - now;
|
|
323
|
-
return { played: false, why: `previous ${slot} still speaking (${remaining} ms left)`, file: resolved.file };
|
|
324
|
-
}
|
|
325
|
-
const subprocess = (() => {
|
|
326
|
-
try { return this.ctx.get('subprocess'); } catch (error) { return undefined; }
|
|
327
|
-
})();
|
|
328
|
-
if (subprocess === undefined || typeof subprocess.spawn !== 'function') {
|
|
329
|
-
if (!this.warnedNoSubprocess) {
|
|
330
|
-
this.warnedNoSubprocess = true;
|
|
331
|
-
this.note('unavailable', 'the subprocess service is absent; audio notifications stay silent');
|
|
332
|
-
}
|
|
333
|
-
return { played: false, why: 'subprocess service unavailable' };
|
|
334
|
-
}
|
|
335
|
-
/* `force` (the settings preview) bypasses this too: a preview whose whole job
|
|
336
|
-
is "let me hear it now" must not be refused because the previous
|
|
337
|
-
announcement has not finished, and the child process is fire-and-forget, so
|
|
338
|
-
overlapping one preview with a notification is harmless. */
|
|
339
|
-
if (!force && this.playing.has(slot)) return { played: false, why: 'previous play still running', file: resolved.file };
|
|
340
|
-
|
|
341
|
-
const file = this.applyVolume(resolved.file, volume);
|
|
342
|
-
const attempts = this.playerCommands(file);
|
|
343
|
-
this.lastPlayed[slot] = now;
|
|
344
|
-
/* Record when this announcement finishes so a second trigger cannot start on
|
|
345
|
-
top of it. Measured from the ORIGINAL file (the cache copy has identical
|
|
346
|
-
duration), plus a small margin for player start-up and process teardown. */
|
|
347
|
-
this.playUntil[slot] = now + this.soundDurationMs(resolved.file) + 250;
|
|
348
|
-
this.playing.add(slot);
|
|
349
|
-
const runtime = this;
|
|
350
|
-
let index = 0;
|
|
351
|
-
const tryNext = () => {
|
|
352
|
-
if (index >= attempts.length) {
|
|
353
|
-
runtime.playing.delete(slot);
|
|
354
|
-
/* Nothing sounded, so the not-speaking window recorded in play() must be
|
|
355
|
-
returned too — otherwise real notifications stay gated for the file's
|
|
356
|
-
whole duration after a total player failure. */
|
|
357
|
-
delete runtime.playUntil[slot];
|
|
358
|
-
runtime.note('failed', `${slot}: no player command succeeded`);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
const argv = attempts[index++];
|
|
362
|
-
let handle;
|
|
363
|
-
try {
|
|
364
|
-
handle = subprocess.spawn({
|
|
365
|
-
argv,
|
|
366
|
-
cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
|
|
367
|
-
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
|
|
368
|
-
graceMs: 8000,
|
|
369
|
-
});
|
|
370
|
-
} catch (error) {
|
|
371
|
-
tryNext();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
Promise.resolve(handle.done).then(
|
|
375
|
-
() => { runtime.playing.delete(slot); },
|
|
376
|
-
(error) => {
|
|
377
|
-
runtime.note('player-failed', `${slot}: ${argv[0]}`);
|
|
378
|
-
tryNext();
|
|
379
|
-
},
|
|
380
|
-
);
|
|
381
|
-
};
|
|
382
|
-
tryNext();
|
|
383
|
-
if (this.diagnosing) {
|
|
384
|
-
this.note('played', `${slot} (${force ? 'preview' : reason || 'event'}) <- ${resolved.origin} volume=${volume}`);
|
|
385
|
-
}
|
|
386
|
-
return { played: true, file: resolved.file, origin: resolved.origin, volume };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Diagnostic snapshot for the settings page. */
|
|
390
|
-
snapshot() {
|
|
391
|
-
return {
|
|
392
|
-
enabled: this.enabled(PREF.enabled),
|
|
393
|
-
volume: this.number(PREF.volume, 0, 100),
|
|
394
|
-
debounceMs: this.number(PREF.debounceMs, 0, 60000),
|
|
395
|
-
humanOnly: this.enabled(PREF.humanOnly),
|
|
396
|
-
slots: SLOT_IDS.map((slot) => {
|
|
397
|
-
const resolved = this.resolve(slot);
|
|
398
|
-
return {
|
|
399
|
-
id: slot,
|
|
400
|
-
switchOn: this.enabled(PREF[slotSwitchKey(slot)]),
|
|
401
|
-
file: resolved === undefined ? null : resolved.file,
|
|
402
|
-
origin: resolved === undefined ? null : resolved.origin,
|
|
403
|
-
bundled: resolved !== undefined && resolved.origin === 'bundled',
|
|
404
|
-
};
|
|
405
|
-
}),
|
|
406
|
-
soundDirs: this.soundDirs(),
|
|
407
|
-
desktop: desktopDir() === undefined ? null : desktopDir(),
|
|
408
|
-
subprocess: (() => {
|
|
409
|
-
try { return typeof this.ctx.get('subprocess')?.spawn === 'function'; } catch (error) { return false; }
|
|
410
|
-
})(),
|
|
411
|
-
diagnosing: this.diagnosing,
|
|
412
|
-
log: this.diag.slice(-40),
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Preference key holding one slot's own switch. */
|
|
418
|
-
function slotSwitchKey(slot) {
|
|
419
|
-
if (slot === 'boot') return 'boot';
|
|
420
|
-
if (slot === 'turn-start') return 'start';
|
|
421
|
-
if (slot === 'turn-done') return 'done';
|
|
422
|
-
if (slot === 'attention') return 'attention';
|
|
423
|
-
return 'fail';
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
module.exports = { AudioRuntime, PREF, FALLBACK, LOG_TAG, desktopDir, BUNDLED_SOUNDS, CACHE_DIR };
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Audio notification engine for the installed (bundle) HOST half.
|
|
4
|
+
*
|
|
5
|
+
* Why the sound is played by the HOST and not in the browser: the moments worth
|
|
6
|
+
* marking are exactly the moments the user is NOT looking at the page. A
|
|
7
|
+
* browser `AudioContext` is suspended until a user gesture and dies with the
|
|
8
|
+
* tab, so a task that finishes while the window is minimized would stay silent.
|
|
9
|
+
* The host owns a real process boundary instead and hands the file to the
|
|
10
|
+
* platform player.
|
|
11
|
+
*
|
|
12
|
+
* Consequences handled here:
|
|
13
|
+
* - `subprocess` is an optional seam. Without it the whole feature degrades to
|
|
14
|
+
* a no-op (and says so once) rather than throwing during boot.
|
|
15
|
+
* - The player is a child process per play. Playback is therefore
|
|
16
|
+
* "fire and forget", latest-wins per slot, with a hard grace timeout.
|
|
17
|
+
* - Volume is applied by rescaling PCM samples into a cache file, never by
|
|
18
|
+
* touching the system volume.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { scaleWavVolume, parseWav } = require('./tone.js');
|
|
25
|
+
const { SLOT_IDS } = require('./slots.js');
|
|
26
|
+
|
|
27
|
+
const LOG_TAG = '[theme-endfield/audio]';
|
|
28
|
+
const BUNDLED_SOUNDS = path.join(__dirname, '..', 'sounds');
|
|
29
|
+
const CACHE_DIR = path.join(os.tmpdir(), 'dsh-theme-endfield');
|
|
30
|
+
|
|
31
|
+
/** Preference keys owned by this feature (all strings, per the namespace convention). */
|
|
32
|
+
const PREF = {
|
|
33
|
+
enabled: 'audioEnabled',
|
|
34
|
+
volume: 'audioVolume',
|
|
35
|
+
boot: 'audioBoot',
|
|
36
|
+
start: 'audioTurnStart',
|
|
37
|
+
done: 'audioTurnDone',
|
|
38
|
+
attention: 'audioAttention',
|
|
39
|
+
fail: 'audioTurnFail',
|
|
40
|
+
debounceMs: 'audioDebounceMs',
|
|
41
|
+
soundDir: 'audioSoundDir',
|
|
42
|
+
humanOnly: 'audioHumanOnly',
|
|
43
|
+
diag: 'audioDiag',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Defaults mirroring FIELD_DEFAULTS in index.js — the master switch ships OFF
|
|
47
|
+
* (sound is opt-in); the per-slot switches ship ON, so turning the master on is
|
|
48
|
+
* what starts the sound. */
|
|
49
|
+
const FALLBACK = {
|
|
50
|
+
[PREF.enabled]: '0',
|
|
51
|
+
[PREF.volume]: '100',
|
|
52
|
+
[PREF.boot]: '1',
|
|
53
|
+
[PREF.start]: '1',
|
|
54
|
+
[PREF.done]: '1',
|
|
55
|
+
[PREF.attention]: '1',
|
|
56
|
+
[PREF.fail]: '1',
|
|
57
|
+
[PREF.debounceMs]: '2500',
|
|
58
|
+
[PREF.soundDir]: '',
|
|
59
|
+
[PREF.humanOnly]: '1',
|
|
60
|
+
[PREF.diag]: '0',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The user's Desktop, probed lazily: a convenient drop-in place for own sounds. */
|
|
64
|
+
function desktopDir() {
|
|
65
|
+
const home = typeof os.homedir === 'function' ? os.homedir() : '';
|
|
66
|
+
if (home === '') return undefined;
|
|
67
|
+
for (const candidate of [path.join(home, 'Desktop'), path.join(home, '桌面')]) {
|
|
68
|
+
try {
|
|
69
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
70
|
+
} catch (error) { /* ignore */ }
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Short stable hash of a path for cache-file naming: FNV-1a, hex. */
|
|
76
|
+
function pathHash(value) {
|
|
77
|
+
let h = 0x811c9dc5;
|
|
78
|
+
for (let i = 0; i < value.length; i++) {
|
|
79
|
+
h ^= value.charCodeAt(i);
|
|
80
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
81
|
+
}
|
|
82
|
+
return h.toString(16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The audio runtime: preference view, sound resolution, volume cache, playback
|
|
87
|
+
* and the diagnostic ring the settings page reads.
|
|
88
|
+
*/
|
|
89
|
+
class AudioRuntime {
|
|
90
|
+
/**
|
|
91
|
+
* @param ctx - the host Cordis context the feature is mounted on.
|
|
92
|
+
*/
|
|
93
|
+
constructor(ctx) {
|
|
94
|
+
this.ctx = ctx;
|
|
95
|
+
this.values = Object.assign({}, FALLBACK);
|
|
96
|
+
this.scope = undefined;
|
|
97
|
+
/** Last play timestamp per slot, for the per-slot debounce window. */
|
|
98
|
+
this.lastPlayed = Object.create(null);
|
|
99
|
+
/** Per slot: the instant its previous announcement finishes speaking. */
|
|
100
|
+
this.playUntil = Object.create(null);
|
|
101
|
+
/** Sound-file duration in ms, memoised per path (read once per file). */
|
|
102
|
+
this.durationCache = new Map();
|
|
103
|
+
/** Slots whose child process has not settled yet — no overlapping play. */
|
|
104
|
+
this.playing = new Set();
|
|
105
|
+
/** Bounded diagnostic ring: what the plugin saw and what it did about it. */
|
|
106
|
+
this.diag = [];
|
|
107
|
+
this.warnedNoSubprocess = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Record one diagnostic line and echo it to the host console. */
|
|
111
|
+
note(kind, detail) {
|
|
112
|
+
const entry = { at: Date.now(), kind, detail: detail === undefined ? '' : String(detail) };
|
|
113
|
+
this.diag.push(entry);
|
|
114
|
+
if (this.diag.length > 60) this.diag.shift();
|
|
115
|
+
console.log(`${LOG_TAG} ${kind}${entry.detail === '' ? '' : ' — ' + entry.detail}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether verbose diagnostics are on (the settings page can switch them on). */
|
|
119
|
+
get diagnosing() {
|
|
120
|
+
return this.values[PREF.diag] !== '0';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Adopt the durable preference snapshot. */
|
|
124
|
+
setValues(values) {
|
|
125
|
+
this.values = Object.assign({}, FALLBACK, values === undefined ? {} : values);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One preference as a string, falling back to the shipped default. */
|
|
129
|
+
value(key) {
|
|
130
|
+
const raw = this.values[key];
|
|
131
|
+
return raw === undefined || raw === null ? FALLBACK[key] : String(raw);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One preference as an integer with bounds. */
|
|
135
|
+
number(key, min, max) {
|
|
136
|
+
const parsed = Number.parseInt(this.value(key), 10);
|
|
137
|
+
if (!Number.isFinite(parsed)) return Number.parseInt(FALLBACK[key], 10);
|
|
138
|
+
return Math.min(max, Math.max(min, parsed));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether one switch preference is on (stored `'1'`, read with `!== '0'`). */
|
|
142
|
+
enabled(key) {
|
|
143
|
+
return this.value(key) !== '0';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Candidate directories for own sound files, most specific first. */
|
|
147
|
+
soundDirs() {
|
|
148
|
+
const dirs = [];
|
|
149
|
+
const configured = this.value(PREF.soundDir);
|
|
150
|
+
if (configured !== '') dirs.push(configured);
|
|
151
|
+
try {
|
|
152
|
+
const workspace = this.ctx.get('workspace');
|
|
153
|
+
const root = workspace === undefined || typeof workspace.root !== 'function' ? undefined : workspace.root();
|
|
154
|
+
if (typeof root === 'string' && root !== '') dirs.push(root);
|
|
155
|
+
} catch (error) { /* workspace service absent — fine */ }
|
|
156
|
+
const desktop = desktopDir();
|
|
157
|
+
if (desktop !== undefined) dirs.push(desktop);
|
|
158
|
+
dirs.push(BUNDLED_SOUNDS);
|
|
159
|
+
return dirs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve one slot to a playable file: configured dirs, then the workspace,
|
|
164
|
+
* then the Desktop drop-in, then the bundled synthesized tone.
|
|
165
|
+
*
|
|
166
|
+
* @param slot - slot id from `lib/slots.js`.
|
|
167
|
+
* @returns `{ file, origin }` or undefined when nothing exists.
|
|
168
|
+
*/
|
|
169
|
+
resolve(slot) {
|
|
170
|
+
const name = `${slot}.wav`;
|
|
171
|
+
const dirs = this.soundDirs();
|
|
172
|
+
for (const dir of dirs) {
|
|
173
|
+
const candidate = path.join(dir, name);
|
|
174
|
+
try {
|
|
175
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
176
|
+
return { file: candidate, origin: dir === BUNDLED_SOUNDS ? 'bundled' : candidate };
|
|
177
|
+
}
|
|
178
|
+
} catch (error) { /* unreadable candidate — try the next */ }
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether one slot's OWN switch is on.
|
|
185
|
+
*
|
|
186
|
+
* Callers that fire on an event already test this before calling `play()`; it
|
|
187
|
+
* exists as a method so the preview route can honour the same switch and report
|
|
188
|
+
* it, instead of playing a sound the user has switched off. `play()` itself
|
|
189
|
+
* deliberately does NOT consult it: `force` means "ignore the debounce", and
|
|
190
|
+
* folding a second meaning into it would make the switch and the preview fight
|
|
191
|
+
* each other.
|
|
192
|
+
*
|
|
193
|
+
* @param slot - slot id from `lib/slots.js`.
|
|
194
|
+
* @returns true when the slot may sound.
|
|
195
|
+
*/
|
|
196
|
+
slotEnabled(slot) {
|
|
197
|
+
const key = PREF[slotSwitchKey(slot)];
|
|
198
|
+
if (key === undefined) return false;
|
|
199
|
+
return this.enabled(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How long one sound file runs, in milliseconds.
|
|
204
|
+
*
|
|
205
|
+
* Used to derive the per-slot "still speaking" gate. The formula is the WAV
|
|
206
|
+
* one — data bytes / (rate * channels * bytes-per-sample) — and a file that
|
|
207
|
+
* cannot be parsed contributes 0, which degrades to the fixed debounce alone
|
|
208
|
+
* rather than to silence.
|
|
209
|
+
*
|
|
210
|
+
* @param file - sound file path.
|
|
211
|
+
* @returns duration in ms (0 when unknown).
|
|
212
|
+
*/
|
|
213
|
+
soundDurationMs(file) {
|
|
214
|
+
const cached = this.durationCache.get(file);
|
|
215
|
+
if (cached !== undefined) return cached;
|
|
216
|
+
let ms = 0;
|
|
217
|
+
try {
|
|
218
|
+
const parsed = parseWav(fs.readFileSync(file));
|
|
219
|
+
if (parsed !== undefined && parsed.sampleRate > 0 && parsed.channels > 0 && parsed.bitsPerSample > 0) {
|
|
220
|
+
ms = Math.round((parsed.dataSize / (parsed.sampleRate * parsed.channels * (parsed.bitsPerSample / 8))) * 1000);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
ms = 0;
|
|
224
|
+
}
|
|
225
|
+
this.durationCache.set(file, ms);
|
|
226
|
+
return ms;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Apply the volume to a sound file by rescaling its PCM into the cache, so the
|
|
231
|
+
* system volume is never touched.
|
|
232
|
+
*
|
|
233
|
+
* @param file - source wav path.
|
|
234
|
+
* @param volume - 0..100.
|
|
235
|
+
* @returns the file to play (the source itself when scaling is a no-op).
|
|
236
|
+
*/
|
|
237
|
+
applyVolume(file, volume) {
|
|
238
|
+
if (volume >= 100) return file;
|
|
239
|
+
let bytes;
|
|
240
|
+
try {
|
|
241
|
+
bytes = fs.readFileSync(file);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return file;
|
|
244
|
+
}
|
|
245
|
+
let scaled;
|
|
246
|
+
try {
|
|
247
|
+
scaled = scaleWavVolume(bytes, volume);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// A malformed user file must degrade to "play it as-is", never escape:
|
|
250
|
+
// play() runs inside the approval / user-questions waterfall callbacks,
|
|
251
|
+
// where a throw would skip next() and break the whole chain (see the
|
|
252
|
+
// tools/execute note in index.js).
|
|
253
|
+
return file;
|
|
254
|
+
}
|
|
255
|
+
if (scaled === bytes) return file; // non-rescalable format: original level
|
|
256
|
+
try {
|
|
257
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
258
|
+
/* The cache key covers the SOURCE DIRECTORY too: basename + size alone
|
|
259
|
+
would let a turn-done.wav from the custom dir collide with an unrelated
|
|
260
|
+
same-named, same-length file from the desktop, and the second user
|
|
261
|
+
would hear the first one's sound. */
|
|
262
|
+
const target = path.join(CACHE_DIR, `${path.basename(file, path.extname(file))}-v${volume}-${bytes.length}-${pathHash(file)}.wav`);
|
|
263
|
+
if (!fs.existsSync(target)) fs.writeFileSync(target, scaled);
|
|
264
|
+
return target;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return file;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The platform command matrix: try each entry until one runs. */
|
|
271
|
+
playerCommands(file) {
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
// PowerShell's Media.SoundPlayer takes a literal path, so quote-escape it
|
|
274
|
+
// and hand the whole script over as -EncodedCommand (no shell quoting
|
|
275
|
+
// games, no console window when run detached).
|
|
276
|
+
const escaped = file.replace(/'/g, "''");
|
|
277
|
+
const script = `$p = New-Object Media.SoundPlayer '${escaped}'; $p.PlaySync()`;
|
|
278
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
279
|
+
const args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded];
|
|
280
|
+
return [
|
|
281
|
+
[path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ...args],
|
|
282
|
+
['pwsh.exe', ...args],
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
if (process.platform === 'darwin') return [['/usr/bin/afplay', file]];
|
|
286
|
+
return [['paplay', file], ['aplay', file]];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Play one slot.
|
|
291
|
+
*
|
|
292
|
+
* @param slot - slot id.
|
|
293
|
+
* @param options - `force` skips the debounce (used by the settings preview),
|
|
294
|
+
* `reason` is recorded in diagnostics.
|
|
295
|
+
* @returns the outcome, for the preview route to report.
|
|
296
|
+
*/
|
|
297
|
+
play(slot, options) {
|
|
298
|
+
const force = options !== undefined && options.force === true;
|
|
299
|
+
const reason = options === undefined || options.reason === undefined ? '' : options.reason;
|
|
300
|
+
if (!SLOT_IDS.includes(slot)) return { played: false, why: `unknown slot "${slot}"` };
|
|
301
|
+
if (!this.enabled(PREF.enabled)) return { played: false, why: 'audio disabled' };
|
|
302
|
+
const volume = this.number(PREF.volume, 0, 100);
|
|
303
|
+
if (volume <= 0) return { played: false, why: 'volume is 0' };
|
|
304
|
+
const resolved = this.resolve(slot);
|
|
305
|
+
if (resolved === undefined) return { played: false, why: 'no sound file found' };
|
|
306
|
+
const debounce = this.number(PREF.debounceMs, 0, 60000);
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
if (!force && now - (this.lastPlayed[slot] || 0) < debounce) {
|
|
309
|
+
return { played: false, why: `debounced (${debounce} ms window)`, file: resolved.file };
|
|
310
|
+
}
|
|
311
|
+
/* Second, independent gate: never start a slot while its own previous
|
|
312
|
+
announcement is still speaking.
|
|
313
|
+
|
|
314
|
+
The fixed window above is a floor for rapid repeats, but a real voice line
|
|
315
|
+
is not a chime: the generated Endfield lines run 3-5 seconds (measured), so
|
|
316
|
+
a 2.5 s window would let one line start on top of another. Deriving a
|
|
317
|
+
per-slot window from the file's own duration means a long announcement is
|
|
318
|
+
never clipped and a short chime is not held back — no per-slot setting to
|
|
319
|
+
keep in sync with whatever audio the user drops in. */
|
|
320
|
+
const playingUntil = this.playUntil[slot] || 0;
|
|
321
|
+
if (!force && now < playingUntil) {
|
|
322
|
+
const remaining = playingUntil - now;
|
|
323
|
+
return { played: false, why: `previous ${slot} still speaking (${remaining} ms left)`, file: resolved.file };
|
|
324
|
+
}
|
|
325
|
+
const subprocess = (() => {
|
|
326
|
+
try { return this.ctx.get('subprocess'); } catch (error) { return undefined; }
|
|
327
|
+
})();
|
|
328
|
+
if (subprocess === undefined || typeof subprocess.spawn !== 'function') {
|
|
329
|
+
if (!this.warnedNoSubprocess) {
|
|
330
|
+
this.warnedNoSubprocess = true;
|
|
331
|
+
this.note('unavailable', 'the subprocess service is absent; audio notifications stay silent');
|
|
332
|
+
}
|
|
333
|
+
return { played: false, why: 'subprocess service unavailable' };
|
|
334
|
+
}
|
|
335
|
+
/* `force` (the settings preview) bypasses this too: a preview whose whole job
|
|
336
|
+
is "let me hear it now" must not be refused because the previous
|
|
337
|
+
announcement has not finished, and the child process is fire-and-forget, so
|
|
338
|
+
overlapping one preview with a notification is harmless. */
|
|
339
|
+
if (!force && this.playing.has(slot)) return { played: false, why: 'previous play still running', file: resolved.file };
|
|
340
|
+
|
|
341
|
+
const file = this.applyVolume(resolved.file, volume);
|
|
342
|
+
const attempts = this.playerCommands(file);
|
|
343
|
+
this.lastPlayed[slot] = now;
|
|
344
|
+
/* Record when this announcement finishes so a second trigger cannot start on
|
|
345
|
+
top of it. Measured from the ORIGINAL file (the cache copy has identical
|
|
346
|
+
duration), plus a small margin for player start-up and process teardown. */
|
|
347
|
+
this.playUntil[slot] = now + this.soundDurationMs(resolved.file) + 250;
|
|
348
|
+
this.playing.add(slot);
|
|
349
|
+
const runtime = this;
|
|
350
|
+
let index = 0;
|
|
351
|
+
const tryNext = () => {
|
|
352
|
+
if (index >= attempts.length) {
|
|
353
|
+
runtime.playing.delete(slot);
|
|
354
|
+
/* Nothing sounded, so the not-speaking window recorded in play() must be
|
|
355
|
+
returned too — otherwise real notifications stay gated for the file's
|
|
356
|
+
whole duration after a total player failure. */
|
|
357
|
+
delete runtime.playUntil[slot];
|
|
358
|
+
runtime.note('failed', `${slot}: no player command succeeded`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const argv = attempts[index++];
|
|
362
|
+
let handle;
|
|
363
|
+
try {
|
|
364
|
+
handle = subprocess.spawn({
|
|
365
|
+
argv,
|
|
366
|
+
cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
|
|
367
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
|
|
368
|
+
graceMs: 8000,
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
tryNext();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
Promise.resolve(handle.done).then(
|
|
375
|
+
() => { runtime.playing.delete(slot); },
|
|
376
|
+
(error) => {
|
|
377
|
+
runtime.note('player-failed', `${slot}: ${argv[0]}`);
|
|
378
|
+
tryNext();
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
};
|
|
382
|
+
tryNext();
|
|
383
|
+
if (this.diagnosing) {
|
|
384
|
+
this.note('played', `${slot} (${force ? 'preview' : reason || 'event'}) <- ${resolved.origin} volume=${volume}`);
|
|
385
|
+
}
|
|
386
|
+
return { played: true, file: resolved.file, origin: resolved.origin, volume };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Diagnostic snapshot for the settings page. */
|
|
390
|
+
snapshot() {
|
|
391
|
+
return {
|
|
392
|
+
enabled: this.enabled(PREF.enabled),
|
|
393
|
+
volume: this.number(PREF.volume, 0, 100),
|
|
394
|
+
debounceMs: this.number(PREF.debounceMs, 0, 60000),
|
|
395
|
+
humanOnly: this.enabled(PREF.humanOnly),
|
|
396
|
+
slots: SLOT_IDS.map((slot) => {
|
|
397
|
+
const resolved = this.resolve(slot);
|
|
398
|
+
return {
|
|
399
|
+
id: slot,
|
|
400
|
+
switchOn: this.enabled(PREF[slotSwitchKey(slot)]),
|
|
401
|
+
file: resolved === undefined ? null : resolved.file,
|
|
402
|
+
origin: resolved === undefined ? null : resolved.origin,
|
|
403
|
+
bundled: resolved !== undefined && resolved.origin === 'bundled',
|
|
404
|
+
};
|
|
405
|
+
}),
|
|
406
|
+
soundDirs: this.soundDirs(),
|
|
407
|
+
desktop: desktopDir() === undefined ? null : desktopDir(),
|
|
408
|
+
subprocess: (() => {
|
|
409
|
+
try { return typeof this.ctx.get('subprocess')?.spawn === 'function'; } catch (error) { return false; }
|
|
410
|
+
})(),
|
|
411
|
+
diagnosing: this.diagnosing,
|
|
412
|
+
log: this.diag.slice(-40),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Preference key holding one slot's own switch. */
|
|
418
|
+
function slotSwitchKey(slot) {
|
|
419
|
+
if (slot === 'boot') return 'boot';
|
|
420
|
+
if (slot === 'turn-start') return 'start';
|
|
421
|
+
if (slot === 'turn-done') return 'done';
|
|
422
|
+
if (slot === 'attention') return 'attention';
|
|
423
|
+
return 'fail';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = { AudioRuntime, PREF, FALLBACK, LOG_TAG, desktopDir, BUNDLED_SOUNDS, CACHE_DIR };
|