dsh-theme-endfield 0.0.0-stage → 1.2.2
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/LICENSE +21 -0
- package/README.md +117 -2
- package/client.js +10152 -0
- package/cordis.patch.yml +17 -0
- package/docs/PR-description.md +141 -0
- package/docs/audio-notifications.md +133 -0
- package/docs/contour-scan-bounds.md +24 -0
- package/docs/contour-trail.md +61 -0
- package/docs/contour-worker.md +38 -0
- package/docs/design-language.md +212 -0
- package/docs/engineering-notes.md +1147 -0
- package/docs/features.md +295 -0
- package/docs/glass.md +32 -0
- package/docs/testing.md +427 -0
- package/index.js +1403 -0
- package/lib/audio.js +426 -0
- package/lib/slots.js +97 -0
- package/lib/tone.js +216 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +111 -4
- package/sounds/attention.wav +0 -0
- package/sounds/boot.wav +0 -0
- package/sounds/turn-done.wav +0 -0
- package/sounds/turn-fail.wav +0 -0
- package/sounds/turn-start.wav +0 -0
package/lib/audio.js
ADDED
|
@@ -0,0 +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 };
|
package/lib/slots.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Sound-slot definitions for the audio-notification feature.
|
|
4
|
+
*
|
|
5
|
+
* One entry per slot, each documented with the moment it marks so the settings
|
|
6
|
+
* page and the docs can describe them without a second source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Wired to events by the host half: `boot` (the loader plates), `turn-start`,
|
|
9
|
+
* `turn-done` and `attention` (approval requests and pending user questions).
|
|
10
|
+
* Generated but NOT wired: `turn-fail` — a turn that fails on its own stays
|
|
11
|
+
* silent by design (see index.js), so the package already carries the whole
|
|
12
|
+
* set and a later version only has to add the trigger.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SLOTS = {
|
|
16
|
+
/**
|
|
17
|
+
* 启动加载动画 —— the boot plate starts. A rising three-note figure that lands
|
|
18
|
+
* on the same G5 the task-end chime begins from, so the boot sound leads INTO
|
|
19
|
+
* that interval instead of competing with it. Shorter and lighter than
|
|
20
|
+
* `turn-start`: it marks a page arriving, not work beginning. Fired once per
|
|
21
|
+
* page load when the boot plate starts — through the host's preview route,
|
|
22
|
+
* which is also what the boot-sound switch in the settings page uses to let
|
|
23
|
+
* you hear it before committing.
|
|
24
|
+
*/
|
|
25
|
+
boot: {
|
|
26
|
+
duration: 0.95,
|
|
27
|
+
peakDbfs: -3,
|
|
28
|
+
notes: [
|
|
29
|
+
{ note: 'G4', start: 0, duration: 0.42, gain: 0.34, timbre: 'blip', decay: 2.2, curve: 2.4 },
|
|
30
|
+
{ note: 'D5', start: 0.11, duration: 0.5, gain: 0.32, timbre: 'blip', decay: 1.9, curve: 2.2 },
|
|
31
|
+
{ note: 'G5', start: 0.22, duration: 0.7, gain: 0.32, timbre: 'chime', decay: 1.5, curve: 2.0 },
|
|
32
|
+
{ note: 'D6', start: 0.34, duration: 0.55, gain: 0.06, timbre: 'chime', decay: 2.0 },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 任务开始 —— the user submitted a prompt from the composer and the agent is
|
|
38
|
+
* picking it up. Short, quiet, informational: it confirms the instruction was
|
|
39
|
+
* received without implying anything finished.
|
|
40
|
+
*/
|
|
41
|
+
'turn-start': {
|
|
42
|
+
duration: 0.5,
|
|
43
|
+
peakDbfs: -4,
|
|
44
|
+
notes: [
|
|
45
|
+
{ note: 'F4', start: 0, duration: 0.3, gain: 0.42, timbre: 'blip', decay: 3.4, curve: 2.6 },
|
|
46
|
+
{ note: 'A#4', start: 0.075, duration: 0.4, gain: 0.34, timbre: 'blip', decay: 3.0, curve: 2.4 },
|
|
47
|
+
{ note: 'C5', start: 0.15, duration: 0.34, gain: 0.22, timbre: 'blip', decay: 3.6, curve: 2.4 },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 任务结束 —— the agent delivered a final text result. Rising major third
|
|
53
|
+
* (C5 -> G5) with an almost inaudible C6 shimmer: closing, not celebratory.
|
|
54
|
+
*/
|
|
55
|
+
'turn-done': {
|
|
56
|
+
duration: 1.15,
|
|
57
|
+
peakDbfs: -2,
|
|
58
|
+
notes: [
|
|
59
|
+
{ note: 'C5', start: 0, duration: 0.55, gain: 0.34, timbre: 'chime' },
|
|
60
|
+
{ note: 'G5', start: 0.16, duration: 0.85, gain: 0.3, timbre: 'chime', decay: 1.0 },
|
|
61
|
+
{ note: 'C6', start: 0.34, duration: 0.75, gain: 0.07, timbre: 'chime', decay: 1.4 },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 需要你回应 (reserved) —— human attention required. Descending minor second
|
|
67
|
+
* with a detuned partial beats against the fundamental: unresolved on
|
|
68
|
+
* purpose, so it does not read as "done".
|
|
69
|
+
*/
|
|
70
|
+
attention: {
|
|
71
|
+
duration: 1.0,
|
|
72
|
+
peakDbfs: -2,
|
|
73
|
+
notes: [
|
|
74
|
+
{ note: 'A4', start: 0, duration: 0.5, gain: 0.34, timbre: 'chime', decay: 1.8 },
|
|
75
|
+
{ note: 'G#4', start: 0.22, duration: 0.8, gain: 0.32, timbre: 'chime', decay: 1.5 },
|
|
76
|
+
{ note: 'A4', start: 0, duration: 0.5, gain: 0.2, timbre: 'chime', detuneCents: 22, decay: 1.8 },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 出错 (reserved) —— the turn failed. Low, hollow, two hits: reads as a fault
|
|
82
|
+
* rather than a completion.
|
|
83
|
+
*/
|
|
84
|
+
'turn-fail': {
|
|
85
|
+
duration: 0.9,
|
|
86
|
+
peakDbfs: -2,
|
|
87
|
+
notes: [
|
|
88
|
+
{ note: 'F3', start: 0, duration: 0.28, gain: 0.4, timbre: 'hollow', decay: 2.6, attack: 0.003 },
|
|
89
|
+
{ note: 'C3', start: 0.2, duration: 0.62, gain: 0.4, timbre: 'hollow', decay: 1.9, attack: 0.003 },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Slot ids in playback/preview order. */
|
|
95
|
+
const SLOT_IDS = ['boot', 'turn-start', 'turn-done', 'attention', 'turn-fail'];
|
|
96
|
+
|
|
97
|
+
module.exports = { SLOTS, SLOT_IDS };
|