dsh-chime-sound 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VviLliAm-qwq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # dsh-chime-sound
2
+
3
+ dsh-tui 提示音效插件(原名 dsh-notice-sound,2026-09-07 更名)/ Notification sounds for dsh-tui.
4
+
5
+ - 触发:女仆调用询问模块 `ask_user_question`(`session/event` `tool/call`,提醒主人回来回答问题)与回复完成(`session/event` `turn/end`,agent-loop 每回合仅发一次;`assistant/message` 会随每个工具步骤多次触发,不采用),区分终端聚焦/未聚焦
6
+ - 4 组音效(清脆电子 / 木质敲击 / 八比特 / 温馨钢琴)× 4 情景,设置页「提示音效」类中可选择或关闭
7
+ - **0.2.0 新增:主音量设置**(设置页「音量 / Volume」项):0%~150%,每档 5%(31 档),默认 100% = 原音量。←/→ 单按 ±5%,**长按连续调节**(dsh-tui 输入层把长按重复展开为逐档事件,无需额外配置);修改后立即试听。>100% 时样本做 16-bit 饱和削波(峰值约 105%,仅极短时段削波;如需无失真可后续改软限幅)。音量仅在插件内部对 WAV 样本增益,不影响系统音量
8
+ - 音效由本仓库 `scripts/gen-sounds.mjs` 生成(WAV,16-bit/48kHz)
9
+ - 设置与文案支持中英文
10
+
11
+ ## ⚠️ 平台限制(Windows only)
12
+
13
+ 本插件通过 **Win32 API**(koffi → `winmm.dll` `PlaySoundW` / `user32.dll` 焦点检测)播放声音,**仅支持 Windows(Windows Terminal / conhost)**。其他平台(Linux/macOS)上插件静默降级为 no-op(不播放、不拖垮启动),并在日志中给出 `Win32-only` 警告。请勿在非 Windows 环境期待提示音。
14
+
15
+ ## 安装
16
+
17
+ **方式一(dsh CLI,推荐):**
18
+
19
+ ```sh
20
+ dsh plugin --profile <profile> add dsh-chime-sound
21
+ ```
22
+
23
+ (或从 dsh 插件市场 / GitHub 安装:`dsh plugin add github:VviLliAm-qwq/dsh-chime`。)
24
+
25
+ **方式二(手动,与生态常见流程一致):**
26
+
27
+ 1. 把插件包复制到 `~/.dsh/profiles/dsh-tui/node_modules/dsh-chime-sound/`
28
+ 2. 在 `~/.dsh/profiles/dsh-tui/package.json` 的 `dsh.profile.bundles` 追加 `"dsh-chime-sound"`(声明了 `dsh.bundle.patch`,启动时自动挂载)
29
+ 3. 重启 dsh-tui(`/restart`)生效
30
+
31
+ **兼容性**:dsh-tui 0.10.x(`ctx.tuiSettingsSections` / `session/event` 软探测接缝);dsh 0.1.2-rc.1+;Node `^22.19 || >=24`;纯 ESM。配置键均有默认值,缺配置时行为退化为"什么都不发生"。
32
+
33
+ ## 配置
34
+
35
+ 设置项在 dsh-tui 设置页「提示音效 / Notice Sounds」类(或 `~/.dsh/settings.yaml` 的 `chime:` 命名空间):
36
+
37
+ ```yaml
38
+ chime:
39
+ doneFocus: g2 # 完成对话·聚焦时: g1-g4 / off
40
+ doneBlur: g2 # 完成对话·未聚焦时
41
+ askFocus: g2 # 提问·聚焦时
42
+ askBlur: g2 # 提问·未聚焦时
43
+ volume: "50" # 0-150, 5% 步进, 默认 "100"
44
+ ```
45
+
46
+ ## 事件源说明(0.1.1 修复)
47
+
48
+ 此前用 dsh-tui 的 mediated `tuiMessageObserver.subscribe()` 订阅消息事件,但该 API 要求插件激活先通过 host admission 绑定已验证的 Component 身份;bundle-patch 挂载的插件不经过 admission,所有订阅被拒绝(`COMPONENT_NOT_ADMITTED`),导致提示音完全不触发。
49
+
50
+ 0.1.1 改用 `@deepseek-ai/dsh-session` 发布的 `session/event` cordis bus 事件(官方 `dsh-working-activity` 扩展同款通道),无需 Component identity:
51
+
52
+ - `tool/call` 且 `name === 'ask_user_question'` → 提问音(askFocus/askBlur)
53
+ - `turn/end` → 完成音(doneFocus/doneBlur),子代理会话不触发
54
+ - 焦点判定:GetForegroundWindow 句柄 == GetConsoleWindow 或窗口类名匹配(`CASCADIA_HOSTING_WINDOW_CLASS`=Windows Terminal / `ConsoleWindowClass`=传统 conhost)。ConPTY 下 GetConsoleWindow 返回隐藏的 `PseudoConsoleWindow`(永非前台),旧代码仅按句柄/类名读法均有缺陷,现双判据+修正的类名读取
55
+ - `dsh-plugin.json` 已移除不再使用的 `messages.observe` 声明
56
+
57
+ 安装后需重启 dsh-tui 生效。
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,4 @@
1
+ # dsh-chime-sound bundle patch: mounts the notification-sound plugin.
2
+ - insert:
3
+ - id: chime
4
+ name: 'dsh-chime-sound'
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://dsh.community/schemas/dsh-plugin-0.15.json",
3
+ "manifestVersion": "0.15",
4
+ "id": "com.dsh-tui.chime",
5
+ "name": "chime",
6
+ "version": "0.2.0",
7
+ "facets": {
8
+ "host": {
9
+ "entry": "lib/index.js",
10
+ "apiVersion": "v1alpha1"
11
+ }
12
+ },
13
+ "requires": {
14
+ "contracts": []
15
+ },
16
+ "permissions": [],
17
+ "subscriptions": [],
18
+ "contributes": {
19
+ "commands": []
20
+ },
21
+ "source": {
22
+ "repository": "https://github.com/VviLliAm-qwq/dsh-chime"
23
+ },
24
+ "compat": {
25
+ "hosts": [
26
+ "dsh-tui"
27
+ ]
28
+ },
29
+ "license": "MIT"
30
+ }
package/lib/index.js ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * dsh-chime — notification sounds for dsh-tui.
3
+ *
4
+ * Plays a distinct sound on two conversation events:
5
+ * - the agent asks the user something via the ask_user_question tool
6
+ * (session/event `tool/call`, `name === 'ask_user_question'`) — the
7
+ * "user has to answer, come back" cue
8
+ * - the assistant turn finishes (session/event `turn/end`)
9
+ * with separate variants for focused vs unfocused terminal windows.
10
+ * Four sound groups (g1 crisp / g2 wood / g3 chiptune / g4 warm) are
11
+ * selectable per scenario in a dedicated settings section (zh/en); a master
12
+ * volume (0%–150%, 5% steps) gains the PCM samples before playback
13
+ * (lib/wav.js scales in memory; PlaySoundW gets SND_MEMORY buffers, so
14
+ * >100% just saturates — no global waveOut volume is touched).
15
+ *
16
+ * Windows integration is done through koffi (user32/kernel32/winmm):
17
+ * focus polling = GetForegroundWindow vs GetConsoleWindow handle OR the
18
+ * window class (CASCADIA_HOSTING_WINDOW_CLASS for Windows Terminal,
19
+ * ConsoleWindowClass for classic conhost; ConPTY's hidden
20
+ * PseudoConsoleWindow is never the foreground), playback via PlaySoundW
21
+ * (async, no console output).
22
+ *
23
+ * Event source: the `session/event` cordis bus, published by
24
+ * `@deepseek-ai/dsh-session` on every committed session event (the same
25
+ * channel the official `dsh-working-activity` extension uses) — NOT the
26
+ * grant-gated `tuiMessageObserver.subscribe` mediated API.
27
+ *
28
+ * Why: a bundle-patch mounted plugin is never put through host admission, so
29
+ * its Cordis activation has no verified dsh-plugin.json Component identity
30
+ * and the mediated observer rejects every subscribe
31
+ * (`COMPONENT_NOT_ADMITTED`), leaving the plugin deaf. The bus subscription
32
+ * needs no identity: the same events, no admission.
33
+ *
34
+ * Host services (settings / settings-sections) are resolved at runtime and
35
+ * retried until they appear — the issue-#183 pattern: an absent service
36
+ * degrades THIS plugin, never the boot.
37
+ *
38
+ * @module dsh-chime
39
+ */
40
+ import { dirname, join } from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { appendFileSync, readFileSync } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import z from '@deepseek-ai/schemastery';
45
+ import koffi from 'koffi';
46
+ import { scaleWav } from './wav.js';
47
+
48
+ const HERE = dirname(fileURLToPath(import.meta.url));
49
+ const SOUND_DIR = join(HERE, '..', 'assets', 'sounds');
50
+ const DIAG_LOG = join(homedir(), '.dsh-tui', 'dsh-chime.log');
51
+
52
+ /** Append a diagnostic line (best-effort; never throws). */
53
+ function diag(line) {
54
+ try { appendFileSync(DIAG_LOG, `${new Date().toISOString()} ${line}\n`); } catch { /* ignore */ }
55
+ }
56
+
57
+ /** Plugin name; always the install id. */
58
+ export const name = 'chime';
59
+
60
+ /** Settings schema: one sound group per scenario plus a master volume. */
61
+ export const Config = z.object({
62
+ doneFocus: z.string().default('g1'),
63
+ doneBlur: z.string().default('g2'),
64
+ askFocus: z.string().default('g3'),
65
+ askBlur: z.string().default('g4'),
66
+ /** Volume in percent, as a string (select values are strings): '0'..'150'. */
67
+ volume: z.string().default('100'),
68
+ });
69
+ /** Explicit boot defaults (mirrors the schema defaults). */
70
+ const DEFAULT_CONFIG = { doneFocus: 'g1', doneBlur: 'g2', askFocus: 'g3', askBlur: 'g4', volume: '100' };
71
+
72
+ /** scenario key -> wav file stem. */
73
+ const SCENARIO_FILE = {
74
+ doneFocus: 'done-focus',
75
+ doneBlur: 'done-blur',
76
+ askFocus: 'ask-focus',
77
+ askBlur: 'ask-blur',
78
+ };
79
+ const GROUP_OPTIONS = [
80
+ { value: 'g1', label: 'Crisp', descriptions: { zh: '清脆电子', en: 'Crisp' } },
81
+ { value: 'g2', label: 'Wood', descriptions: { zh: '木质敲击', en: 'Wood' } },
82
+ { value: 'g3', label: 'Chiptune', descriptions: { zh: '八比特', en: 'Chiptune' } },
83
+ { value: 'g4', label: 'Warm', descriptions: { zh: '温馨钢琴', en: 'Warm' } },
84
+ { value: 'off', label: 'Off', descriptions: { zh: '关闭', en: 'Off' } },
85
+ ];
86
+ /** Volume choices: 0%–150% in 5% steps (stored as plain value strings). */
87
+ const VOLUME_OPTIONS = Array.from({ length: 31 }, (_, i) => {
88
+ const v = i * 5;
89
+ return { value: String(v), label: `${v}%` };
90
+ });
91
+ const VOLUME_MIN = 0;
92
+ const VOLUME_MAX = 150;
93
+ /** scenario field key -> [en label, localized descriptions]. */
94
+ const FIELD_LABELS = {
95
+ doneFocus: ['Done (focused)', { zh: '完成对话·聚焦时', en: 'Done (focused)' }],
96
+ doneBlur: ['Done (unfocused)', { zh: '完成对话·未聚焦时', en: 'Done (unfocused)' }],
97
+ askFocus: ['Question (focused)', { zh: '提问·聚焦时', en: 'Question (focused)' }],
98
+ askBlur: ['Question (unfocused)', { zh: '提问·未聚焦时', en: 'Question (unfocused)' }],
99
+ };
100
+
101
+ // Win32 constants for PlaySoundW (memory playback, async, no system beep).
102
+ const SND_MEMORY = 0x0004;
103
+ const SND_ASYNC = 0x0001;
104
+ const SND_NODEFAULT = 0x0002;
105
+ const THROTTLE_MS = 700;
106
+ const RETRY_MS = 400;
107
+ const RETRY_LIMIT = 60; // ~24s of service readiness attempts
108
+ // Playback pool: SND_MEMORY playback needs the buffer alive until the sound
109
+ // finishes; slots are reused only after a long cooldown (sounds ≤1.3s, so 8
110
+ // slots × 5s covers 40s of uninterrupted play — an idle slot is a safe one).
111
+ const POOL_SIZE = 8;
112
+ const POOL_COOLDOWN_MS = 5000;
113
+
114
+ /** Lazy, shared Win32 bindings (koffi). */
115
+ let win32 = null;
116
+ function loadWin32() {
117
+ if (win32 === null) {
118
+ const pvoid = koffi.pointer('void');
119
+ const api = (lib, name, result, args) => lib.func('__stdcall', name, result, args);
120
+ const user32 = koffi.load('user32.dll');
121
+ const winmm = koffi.load('winmm.dll');
122
+ const kernel32 = koffi.load('kernel32');
123
+ const win32Local = {
124
+ getForegroundWindow: api(user32, 'GetForegroundWindow', 'uintptr_t', []),
125
+ getClassNameW: api(user32, 'GetClassNameW', 'int', ['uintptr_t', 'void*', 'int']),
126
+ playSound: api(winmm, 'PlaySoundW', 'int', ['void*', pvoid, 'uint32']),
127
+ consoleWindow: null,
128
+ // console hosts accept these two top-level window classes (fallback
129
+ // only — the primary check is the handle comparison below)
130
+ hostClasses: ['ConsoleWindowClass', 'CASCADIA_HOSTING_WINDOW_CLASS'],
131
+ };
132
+ try {
133
+ win32Local.consoleWindow = api(kernel32, 'GetConsoleWindow', 'uintptr_t', []);
134
+ }
135
+ catch {
136
+ win32Local.consoleWindow = null;
137
+ }
138
+ win32 = win32Local;
139
+ }
140
+ return win32;
141
+ }
142
+
143
+ /**
144
+ * Whether the current foreground window is THIS terminal window.
145
+ * Two reliable checks, OR'ed:
146
+ * 1. handle equality with GetConsoleWindow — classic console hosts expose
147
+ * their own top-level window there;
148
+ * 2. class-name match — Windows Terminal (CASCADIA_HOSTING_WINDOW_CLASS)
149
+ * and classic conhost (ConsoleWindowClass); needed because on ConPTY
150
+ * GetConsoleWindow returns the hidden PseudoConsoleWindow, never the
151
+ * foreground window. Class text is read as `n` UTF-16 chars (the
152
+ * byte-index scan miss-reads the alternating NUL bytes).
153
+ */
154
+ function isTerminalFocused(w) {
155
+ try {
156
+ const fg = w.getForegroundWindow();
157
+ if (fg === null || fg === 0) return false;
158
+ if (w.consoleWindow !== null) {
159
+ const own = w.consoleWindow();
160
+ if (own !== null && own !== 0 && fg === own) return true;
161
+ }
162
+ const buf = Buffer.alloc(400);
163
+ const n = w.getClassNameW(fg, buf, 200);
164
+ if (n > 0) {
165
+ const cls = buf.subarray(0, Math.min(n, 200) * 2).toString('utf16le');
166
+ if (w.hostClasses.includes(cls)) return true;
167
+ }
168
+ return false;
169
+ }
170
+ catch {
171
+ return true; // keep the previous (focused) assumption on Win32 hiccups
172
+ }
173
+ }
174
+
175
+ function sessionIdOf(session) {
176
+ if (session == null) return undefined;
177
+ return session.id ?? session.header?.id ?? session.sessionId;
178
+ }
179
+
180
+ /** Whether a session is a subagent child (its own event stream). */
181
+ function isSubagentSession(session) {
182
+ try {
183
+ const header = session?.header ?? session?.meta;
184
+ return header?.origin === 'subagent' || (header?.delegationDepth ?? 0) > 0;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ /** Mount the settings section (display metadata only). */
192
+ function registerSection(sections) {
193
+ const fields = [
194
+ {
195
+ path: ['volume'],
196
+ label: 'Volume',
197
+ descriptions: { zh: '音量', en: 'Volume' },
198
+ hint: '←/→ steps 5%, hold to repeat; >100% may clip',
199
+ hintDescriptions: { zh: '←/→ 每次 ±5%,长按连续调节;超过 100% 可能削波', en: '←/→ steps 5%, hold to repeat; >100% may clip' },
200
+ kind: 'select',
201
+ options: VOLUME_OPTIONS,
202
+ },
203
+ ...['doneFocus', 'doneBlur', 'askFocus', 'askBlur'].map((key) => {
204
+ const [label, descriptions] = FIELD_LABELS[key];
205
+ return {
206
+ path: [key],
207
+ label,
208
+ descriptions,
209
+ kind: 'select',
210
+ options: GROUP_OPTIONS,
211
+ };
212
+ }),
213
+ ];
214
+ return sections.register({
215
+ ns: 'chime',
216
+ title: 'Notice Sounds',
217
+ descriptions: { zh: '提示音效', en: 'Notice Sounds' },
218
+ fields,
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Cordis plugin entry. All services are resolved at runtime through
224
+ * ctx.get (retried until they appear) so an absent/late service degrades
225
+ * this plugin, never the boot. Message events come from the session/event
226
+ * bus (no Component identity required — see module header).
227
+ */
228
+ export function apply(ctx, config) {
229
+ diag('apply started');
230
+ if (process.platform !== 'win32') {
231
+ // TUI-RUN-001: the plugin is Win32-only (winmm/user32 via koffi); on
232
+ // other platforms it degrades to a no-op — never a boot failure.
233
+ diag(`platform=${process.platform}: sounds disabled (Win32-only)`);
234
+ ctx.logger.warn(`chime: Win32-only plugin (winmm/user32); notification sounds will not play on ${process.platform}`);
235
+ }
236
+ ctx.effect(function* () {
237
+ let scope = null;
238
+ let sectionRegistered = false;
239
+ let sectionDisposer = null;
240
+ let focusStarted = false;
241
+ let attempts = 0;
242
+ let retryTimer = null;
243
+
244
+ // ── playback state ────────────────────────────────────────────
245
+ const lastPlay = new Map();
246
+ const wavCache = new Map(); // wav file path -> source Buffer
247
+ const playPool = Array.from({ length: POOL_SIZE }, () => ({ buf: null, usedAt: 0 }));
248
+ let wrappedPrev = { ...DEFAULT_CONFIG };
249
+ let focused = true;
250
+ let focusTimer = null;
251
+ let loaded = null;
252
+
253
+ /** Clamp the stored volume string to 0..150 (invalid -> 100). */
254
+ function clampVolume(raw) {
255
+ const n = Number.parseInt(String(raw ?? ''), 10);
256
+ if (!Number.isFinite(n)) return 100;
257
+ return Math.min(VOLUME_MAX, Math.max(VOLUME_MIN, n));
258
+ }
259
+
260
+ /** Oldest pool slot past its cooldown, else the oldest slot (reuse).
261
+ * All slots start cold, so a playing buffer is never overwritten
262
+ * within 5s — far beyond the longest sound's 1.3s. */
263
+ function takePoolSlot() {
264
+ const now = Date.now();
265
+ let candidate = null;
266
+ let oldest = playPool[0];
267
+ for (const slot of playPool) {
268
+ if (slot.usedAt < oldest.usedAt) oldest = slot;
269
+ if (now - slot.usedAt > POOL_COOLDOWN_MS &&
270
+ (candidate === null || slot.usedAt < candidate.usedAt)) {
271
+ candidate = slot;
272
+ }
273
+ }
274
+ const slot = candidate ?? oldest;
275
+ slot.usedAt = now;
276
+ return slot;
277
+ }
278
+
279
+ function play(scenarioKey) {
280
+ try {
281
+ const cfg = scope !== null && scope.get() ? { ...DEFAULT_CONFIG, ...scope.get() } : { ...DEFAULT_CONFIG };
282
+ const group = cfg[scenarioKey];
283
+ if (group === undefined || group === null || group === 'off') { diag(`play ${scenarioKey}: off/empty`); return; }
284
+ const now = Date.now();
285
+ const prev = lastPlay.get(scenarioKey) ?? 0;
286
+ if (now - prev < THROTTLE_MS) { diag(`play ${scenarioKey}: throttled`); return; }
287
+ lastPlay.set(scenarioKey, now);
288
+ const file = join(SOUND_DIR, `${group}-${SCENARIO_FILE[scenarioKey]}.wav`);
289
+ const volume = clampVolume(cfg.volume);
290
+ let source = wavCache.get(file);
291
+ if (source === undefined) {
292
+ source = readFileSync(file);
293
+ wavCache.set(file, source);
294
+ }
295
+ const buffer = scaleWav(source, volume);
296
+ const slot = takePoolSlot();
297
+ slot.buf = buffer; // keep alive while winmm plays it
298
+ loaded ??= loadWin32();
299
+ const result = loaded.playSound(buffer, null, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
300
+ diag(`play ${scenarioKey}: group=${group} file=${file} volume=${volume}% winmm=${result}`);
301
+ }
302
+ catch (error) {
303
+ diag(`play FAIL ${scenarioKey}: ${error instanceof Error ? error.message : String(error)}`);
304
+ ctx.logger.warn(`chime: play failed: ${error instanceof Error ? error.message : String(error)}`);
305
+ }
306
+ }
307
+
308
+ // ── session/event bus listener (one subscription, all sessions) ─
309
+ // Diag keeps only low-volume events: assistant/chunk fires hundreds of
310
+ // times per second and would grow the log unboundedly.
311
+ const DIAG_TYPES = new Set([
312
+ 'user/message', 'turn/start', 'turn/end', 'assistant/message',
313
+ 'tool/call', 'tool/result', 'step/start', 'step/end', 'request/header',
314
+ ]);
315
+ function onSessionEvent(session, event) {
316
+ const type = event?.type;
317
+ if (DIAG_TYPES.has(type)) diag(`session/event type=${type} session=${sessionIdOf(session) ?? '?'}`);
318
+ if (type === 'tool/call' && event?.data?.name === 'ask_user_question') {
319
+ // The maid invoked the user-question module: the user has to
320
+ // answer something — cue them even when the terminal is blurred.
321
+ diag(`ask_user_question detected (session=${sessionIdOf(session) ?? '?'})`);
322
+ play(focused ? 'askFocus' : 'askBlur');
323
+ }
324
+ else if (type === 'turn/end') {
325
+ // agent-loop emits exactly one turn/end per assistant round
326
+ // (assistant/message would fire once per tool step — noise).
327
+ // Subagent children have their own stream; a parent's turn is
328
+ // what the user waits on, so their sound would be noise too.
329
+ if (isSubagentSession(session)) return;
330
+ play(focused ? 'doneFocus' : 'doneBlur');
331
+ }
332
+ }
333
+ ctx.on('session/event', onSessionEvent);
334
+ diag('session/event listener attached');
335
+
336
+ // ── focus polling (console-window handle based) ───────────────
337
+ function startFocus() {
338
+ if (focusStarted) return;
339
+ try {
340
+ const w = loadWin32();
341
+ focusStarted = true;
342
+ focusTimer = setInterval(() => {
343
+ try {
344
+ const isFocused = isTerminalFocused(w);
345
+ if (isFocused !== focused) {
346
+ focused = isFocused;
347
+ ctx.logger.debug(`chime: terminal ${focused ? 'focused' : 'blurred'}`);
348
+ }
349
+ }
350
+ catch {
351
+ // transient Win32 failure — keep the last known state
352
+ }
353
+ }, 750);
354
+ }
355
+ catch (error) {
356
+ ctx.logger.warn(`chime: focus detection unavailable (always treated as focused): ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ }
359
+
360
+ // ── service-readiness poll ────────────────────────────────────
361
+ function tryOnce() {
362
+ // settings namespace + settings section
363
+ if (scope === null) {
364
+ const settings = ctx.get('settings');
365
+ if (settings !== undefined) {
366
+ try {
367
+ scope = settings.register('chime', Config);
368
+ diag('settings namespace registered');
369
+ ctx.logger.info('chime: settings namespace registered');
370
+ // preview: play the newly selected group for the changed scenario
371
+ let prev = { ...DEFAULT_CONFIG };
372
+ try { prev = { ...DEFAULT_CONFIG, ...scope.get() }; } catch { /* ignore */ }
373
+ scope.watch((next) => {
374
+ const merged = { ...DEFAULT_CONFIG, ...next };
375
+ let played = false;
376
+ for (const key of ['doneFocus', 'doneBlur', 'askFocus', 'askBlur']) {
377
+ if (merged[key] !== undefined && wrappedPrev[key] !== merged[key] && merged[key] !== 'off') {
378
+ play(key);
379
+ played = true;
380
+ }
381
+ }
382
+ // Volume-only change: preview one live scenario so the
383
+ // user hears the new level immediately (group-change
384
+ // previews above take precedence).
385
+ if (!played && merged.volume !== wrappedPrev.volume) {
386
+ const key = ['doneFocus', 'doneBlur', 'askFocus', 'askBlur']
387
+ .find(k => merged[k] !== undefined && merged[k] !== 'off');
388
+ if (key !== undefined) play(key);
389
+ }
390
+ wrappedPrev = { ...merged };
391
+ });
392
+ }
393
+ catch (error) {
394
+ ctx.logger.warn(`chime: settings register failed: ${error instanceof Error ? error.message : String(error)}`);
395
+ }
396
+ }
397
+ }
398
+ if (!sectionRegistered) {
399
+ const sections = ctx.get('tuiSettingsSections');
400
+ if (sections !== undefined) {
401
+ try {
402
+ sectionDisposer = registerSection(sections);
403
+ sectionRegistered = true;
404
+ }
405
+ catch (error) {
406
+ ctx.logger.warn(`chime: settings section failed: ${error instanceof Error ? error.message : String(error)}`);
407
+ }
408
+ }
409
+ }
410
+ startFocus();
411
+
412
+ // done?
413
+ if (scope !== null && sectionRegistered && focusStarted) {
414
+ if (retryTimer !== null) { clearInterval(retryTimer); retryTimer = null; }
415
+ diag('readiness complete');
416
+ }
417
+ else if (++attempts >= RETRY_LIMIT) {
418
+ diag(`readiness timeout (settings=${scope !== null}, section=${sectionRegistered}, focus=${focusStarted})`);
419
+ ctx.logger.warn(`chime: services did not all appear within ${(RETRY_LIMIT * RETRY_MS) / 1000}s (settings=${scope !== null}, section=${sectionRegistered}, focus=${focusStarted})`);
420
+ if (retryTimer !== null) { clearInterval(retryTimer); retryTimer = null; }
421
+ }
422
+ }
423
+
424
+ retryTimer = setInterval(tryOnce, RETRY_MS);
425
+ tryOnce();
426
+
427
+ yield () => {
428
+ if (retryTimer !== null) clearInterval(retryTimer);
429
+ if (focusTimer !== null) clearInterval(focusTimer);
430
+ try { sectionDisposer?.(); } catch { /* best-effort */ }
431
+ // The session/event listener is owned by this activation: cordis
432
+ // removes it on fiber teardown, nothing else to do.
433
+ };
434
+ }, 'chime lifecycle');
435
+ }
package/lib/wav.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * dsh-chime — WAV gain scaling (pure functions, no I/O).
3
+ *
4
+ * `scaleWav` scales a RIFF/WAVE (PCM, 16-bit) buffer's samples by
5
+ * `volumePercent / 100` with 16-bit saturation, preserving the original
6
+ * layout (header and unknown chunks untouched, only the `data` block is
7
+ * rewritten). The result can be handed to PlaySoundW with SND_MEMORY.
8
+ *
9
+ * >100% gain saturates at ±32767 — a short notification tone tolerates the
10
+ * clipping; a soft-limiter pass could be a future refinement.
11
+ *
12
+ * @module dsh-chime/wav
13
+ */
14
+
15
+ const FORMAT_PCM = 1;
16
+
17
+ /** Read a RIFF chunk; returns `{ header, size }` (header = chunk payload offset). */
18
+ function findChunk(buf, id, from) {
19
+ let off = from;
20
+ while (off + 8 <= buf.length) {
21
+ if (buf.toString('ascii', off, off + 4) === id) {
22
+ return { header: off + 8, size: buf.readUInt32LE(off + 4) };
23
+ }
24
+ const size = buf.readUInt32LE(off + 4);
25
+ off += 8 + size + (size % 2); // chunks pad to even lengths
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Scale the PCM samples of a WAV buffer by `volumePercent / 100`.
32
+ *
33
+ * Supports PCM only (format 1, any channel count, 16-bit samples — exactly
34
+ * what `scripts/gen-sounds.mjs` produces). 100% returns the input buffer
35
+ * unchanged; the caller keeps the buffer alive as long as it is in use.
36
+ *
37
+ * @param {Buffer} wav - Source WAV (RIFF/WAVE, PCM 16-bit).
38
+ * @param {number|string} volumePercent - 0..150 (clamped); anything invalid
39
+ * falls back to 100.
40
+ * @returns {Buffer} The scaled buffer (same Buffer for 100%, otherwise a copy).
41
+ */
42
+ export function scaleWav(wav, volumePercent) {
43
+ if (!Buffer.isBuffer(wav)) throw new TypeError('scaleWav: expected a Buffer');
44
+ if (wav.length < 44) throw new Error('scaleWav: not a WAV (too short)');
45
+ if (wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') {
46
+ throw new Error('scaleWav: not a RIFF/WAVE file');
47
+ }
48
+ const fmt = findChunk(wav, 'fmt ', 12);
49
+ const data = findChunk(wav, 'data', 12);
50
+ if (fmt === null || data === null) throw new Error('scaleWav: missing fmt or data chunk');
51
+ const format = fmt.header + 0;
52
+ const channels = fmt.header + 2;
53
+ const bitsPerSample = fmt.header + 14;
54
+ if (wav.readUInt16LE(format) !== FORMAT_PCM) throw new Error('scaleWav: only PCM WAV supported');
55
+ if (wav.readUInt16LE(bitsPerSample) !== 16) throw new Error('scaleWav: only 16-bit PCM supported');
56
+ if (wav.readUInt16LE(channels) < 1) throw new Error('scaleWav: bad channel count');
57
+
58
+ const raw = Number.parseFloat(String(volumePercent ?? ''));
59
+ const value = Number.isFinite(raw) ? raw : 100;
60
+ const gain = Math.min(150, Math.max(0, value)) / 100;
61
+ if (gain === 1) return wav;
62
+
63
+ const count = Math.floor(data.size / 2);
64
+ const out = Buffer.allocUnsafe(wav.length);
65
+ wav.copy(out);
66
+ for (let i = 0; i < count; i++) {
67
+ const at = data.header + i * 2;
68
+ const scaled = Math.round(wav.readInt16LE(at) * gain);
69
+ out.writeInt16LE(scaled > 32767 ? 32767 : scaled < -32768 ? -32768 : scaled, at);
70
+ }
71
+ return out;
72
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "dsh-chime-sound",
3
+ "version": "0.2.0",
4
+ "description": "Notification sounds for dsh-tui: distinct tones for conversation done / user question, focused vs unfocused terminal, 4 selectable sound groups, master volume 0-150% in 5% steps, settings section, zh/en",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/VviLliAm-qwq/dsh-chime.git"
14
+ },
15
+ "author": "VviLliAm-qwq",
16
+ "keywords": [
17
+ "dsh",
18
+ "dsh-plugin",
19
+ "dsh-tui",
20
+ "deepseek-harness",
21
+ "notification",
22
+ "sound",
23
+ "chime",
24
+ "win32"
25
+ ],
26
+ "files": [
27
+ "lib",
28
+ "assets",
29
+ "dsh-plugin.json",
30
+ "cordis.patch.yml",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ }
38
+ },
39
+ "engines": {
40
+ "node": "^22.19 || >=24"
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/cordis": "^4.0.1",
44
+ "@deepseek-ai/schemastery": "^3.18.1",
45
+ "koffi": "^3.1.0"
46
+ }
47
+ }