dsh-notify-chime 1.4.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,13 @@
7
7
  | 回合正常结束 | `finished` | 上行三音 |
8
8
  | 中断(停止、归档、取消) | `interrupted` | 下行两音 |
9
9
  | 出错结束 | `error` | 低频双响 |
10
- | Agent 提问(等回答 / 等批计划) | `question` | 三声高音 |
10
+ | Agent 提问(等回答) | `question` | 三声高音 |
11
+ | 等待审批(`approval/asked`) | `approval` | 上行双音,稍急 |
12
+ | 计划评审(`exit_plan_mode`) | `plan-review` | 三音上行 |
13
+ | 目标受阻(`goal/change` → `phase: blocked`) | `goal-blocked` | 低频下行 |
14
+ | 后台任务失败(`job/settled` → `failed`) | `job-failed` | 短促双音 |
15
+
16
+ 计划评审以前混在「提问」里,1.5.0 起单独分类。目标受阻只在「非 blocked → blocked」那一次跃迁发声;后台任务失败按会话 3 秒窗口去抖,多个任务同时失败只响一次。
11
17
 
12
18
  应用退出、会话分叉、被阻断,以及默认被过滤的 subagent 会话不发声。
13
19
 
@@ -25,7 +31,7 @@ dsh plugin add dsh-notify-chime
25
31
 
26
32
  | 组 | 内容 |
27
33
  | --- | --- |
28
- | 声音 | 总开关、音色、四类事件各自开关与试听、最小间隔、子会话提醒 |
34
+ | 声音 | 总开关、音色、八类事件各自开关与试听、正在看的会话完成时不响、同类事件最小间隔、子会话提醒 |
29
35
  | 通知 | 通知总开关、哪些事件弹、标题前缀、正文是否带会话名 |
30
36
  | 高级 | 设置文件 / 音效 / 自定义 / 轨迹目录(只读)、恢复默认 |
31
37
 
@@ -54,7 +60,7 @@ dsh plugin add dsh-notify-chime
54
60
 
55
61
  音色为「自定义」的行会出现「上传」按钮:
56
62
 
57
- - 只收 WAV(`System.Media.SoundPlayer` 的限制),单文件 ≤ 5 MB
63
+ - 支持 **WAV / MP3 / M4A / AAC**,单文件 ≤ 5 MB;WAV 走 `SoundPlayer`,其它走 WPF `MediaPlayer`(借系统解码器)
58
64
  - 存 `~/.dsh/dsh-notify-chime-sounds/<kind>.wav`,卸载插件不丢
59
65
  - 上传后该行切到「自定义」,其它事件的音色不变;没传的类仍用内置音效
60
66
  - 上传即生效,可直接点「试听」确认
@@ -70,7 +76,15 @@ dsh plugin add dsh-notify-chime
70
76
  soundset: default
71
77
  minIntervalMs: 700
72
78
  includeSubagents: false
73
- kinds: { finished: true, interrupted: true, error: true, question: true }
79
+ kinds:
80
+ finished: true
81
+ interrupted: true
82
+ error: true
83
+ question: true
84
+ approval: true
85
+ plan-review: true
86
+ goal-blocked: true
87
+ job-failed: true
74
88
  toast: { enabled: true, kinds: [question], includeSession: true }
75
89
  ```
76
90
 
@@ -80,10 +94,11 @@ dsh plugin add dsh-notify-chime
80
94
  | `soundset` | `default` | 全局音色:`default` / `soft` / `chime` / `retro` / `digital` / `wood` / `custom` |
81
95
  | `minIntervalMs` | `700` | 同类事件最小间隔,防连响 |
82
96
  | `includeSubagents` | `false` | 是否也为子会话发声 |
97
+ | `quietCurrent` | `false` | 正在查看的那个会话完成时不发声(只影响完成,审批/提问/出错照响) |
83
98
  | `kinds.<kind>` | `true` | 该类事件是否发声 |
84
99
  | `sounds.<kind>` | 内置 wav | 单类音效路径;`false` 或 `null` 表示不响 |
85
- | `player` | `auto` | `auto` / `powershell` / `pwsh` / `none` / 播放器路径 |
86
- | `questionTools` | `["ask_user_question", "exit_plan_mode"]` | 视为提问的工具名 |
100
+ | `player` | `auto` | `auto` / `powershell` / `pwsh` / `wpf` / `none` / 播放器路径。`auto` 时 wav 用 `SoundPlayer`、其它格式用 WPF `MediaPlayer` |
101
+ | `questionTools` | `["ask_user_question"]` | 只决定谁触发 `question` 音。`exit_plan_mode` 固定归 `plan-review`,想静音它请关 `kinds.plan-review` |
87
102
  | `toast.enabled` | `false` | 是否弹系统通知(本包初值为 `true`) |
88
103
  | `toast.kinds` | `null` | 只对哪些 kind 弹,`null` 为全部 |
89
104
  | `toast.title` | `DeepSeek Harness` | 通知标题前缀,最长 60 字符 |
@@ -140,7 +155,7 @@ node tools/smoke-test.mjs --verbose
140
155
  - 监听会话日志的追加流,冷启动打开历史会话不补响。
141
156
  - 每次提醒 spawn 独立子进程播放,不阻塞 Host。PowerShell 冷启动约 200 ms,延迟约 0.3 s。
142
157
  - 无音量调节,跟随系统默认播放设备;默认设备是虚拟声卡时人耳可能听不到。
143
- - 自定义音效只支持 WAV,不支持 mp3 / flac。
158
+ - 自定义音效支持 WAV / MP3 / M4A / AAC;WMA、FLAC 不支持(系统虽有解码器,但没有接)。
144
159
  - 目录:`lib/`(宿主)、`client.js`(设置页)、`assets/`(音效)、`tools/`(脚本)。
145
160
 
146
161
  ## 音效来源
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
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/client.js CHANGED
@@ -32,6 +32,10 @@ window.__ModuleLoader__.load({
32
32
  { key: 'interrupted', label: '工作中断', sound: null, soundExists: true },
33
33
  { key: 'error', label: '工作出错', sound: null, soundExists: true },
34
34
  { key: 'question', label: 'Agent 提问', sound: null, soundExists: true },
35
+ { key: 'approval', label: '等待审批', sound: null, soundExists: true },
36
+ { key: 'plan-review', label: '计划评审', sound: null, soundExists: true },
37
+ { key: 'goal-blocked', label: '目标受阻', sound: null, soundExists: true },
38
+ { key: 'job-failed', label: '后台任务失败', sound: null, soundExists: true },
35
39
  ]
36
40
 
37
41
  /** 宿主没返回音色套清单时的兜底。 */
@@ -48,6 +52,7 @@ window.__ModuleLoader__.load({
48
52
  const EMPTY_SETTINGS = {
49
53
  enabled: true,
50
54
  soundset: 'default',
55
+ quietCurrent: false,
51
56
  perKindSoundset: {},
52
57
  customSounds: {},
53
58
  kinds: { finished: true, interrupted: true, error: true, question: true },
@@ -73,10 +78,27 @@ window.__ModuleLoader__.load({
73
78
  }
74
79
  const listeners = new Set()
75
80
  const emit = () => { for (const fn of listeners) fn(state) }
81
+ /** apply 时保存的客户端上下文,供 refresh 读取「当前会话」。 */
82
+ let pluginCtx = null
83
+
84
+ /** 读客户端当前打开的会话 id;拿不到就返回 null(该功能温和失效,不影响其它能力)。 */
85
+ function currentSessionId () {
86
+ try {
87
+ const sessions = pluginCtx && typeof pluginCtx.get === 'function' ? pluginCtx.get('sessions') : undefined
88
+ const list = sessions && sessions.list
89
+ const snap = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : undefined
90
+ const id = snap ? snap.current : undefined
91
+ return typeof id === 'string' && id.length > 0 ? id : null
92
+ } catch {
93
+ return null
94
+ }
95
+ }
76
96
 
77
97
  async function refresh () {
78
98
  try {
79
- const r = await fetch(API + '/state', { headers: { accept: 'application/json' } })
99
+ const current = currentSessionId()
100
+ const url = API + '/state' + (current ? '?current=' + encodeURIComponent(current) : '')
101
+ const r = await fetch(url, { headers: { accept: 'application/json' } })
80
102
  if (!r.ok) throw new Error('HTTP ' + r.status)
81
103
  const j = await r.json()
82
104
  state = { ...state, loading: false, error: null, ...j }
@@ -152,8 +174,9 @@ window.__ModuleLoader__.load({
152
174
  sectionHint: { fontSize: '12px', color: C.dim, flex: 1, minWidth: 0 },
153
175
  chevron: { fontSize: '11px', color: C.dim, marginLeft: 'auto', flex: 'none' },
154
176
  sectionBody: { padding: '12px 14px', display: 'grid', gap: '12px' },
155
- row: { display: 'flex', alignItems: 'flex-start', gap: '12px' },
156
- rowText: { display: 'grid', gap: '2px', flex: 1, minWidth: 0 },
177
+ // 行布局自适应:空间不够时控件整体换到下一行,而不是把标题挤成一字一列
178
+ row: { display: 'flex', alignItems: 'flex-start', gap: '12px', flexWrap: 'wrap' },
179
+ rowText: { display: 'grid', gap: '2px', flex: '1 1 200px', minWidth: '180px' },
157
180
  rowTitle: { fontSize: '13px', fontWeight: 550 },
158
181
  rowDesc: { fontSize: '12px', color: C.dim, lineHeight: 1.5 },
159
182
  toggle: (on) => ({
@@ -165,13 +188,13 @@ window.__ModuleLoader__.load({
165
188
  position: 'absolute', top: '2px', left: on ? '18px' : '2px', width: '16px', height: '16px',
166
189
  borderRadius: '50%', background: '#fff', transition: 'left 160ms ease',
167
190
  }),
168
- inline: { display: 'flex', alignItems: 'center', gap: '8px', flex: 'none' },
191
+ inline: { display: 'flex', alignItems: 'center', gap: '8px', flex: '0 1 auto', flexWrap: 'wrap', justifyContent: 'flex-end' },
169
192
  input: {
170
193
  width: '110px', padding: '5px 8px', fontSize: '13px', textAlign: 'right',
171
194
  border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
172
195
  },
173
196
  inputWide: {
174
- flex: 1, minWidth: 0, padding: '5px 8px', fontSize: '13px',
197
+ flex: '1 1 160px', minWidth: '160px', padding: '5px 8px', fontSize: '13px',
175
198
  border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
176
199
  },
177
200
  btn: (disabled) => ({
@@ -179,7 +202,9 @@ window.__ModuleLoader__.load({
179
202
  border: '1px solid ' + C.border, background: 'transparent', color: C.text,
180
203
  cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, flex: 'none',
181
204
  }),
182
- chips: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: 'none' },
205
+ chips: { display: 'flex', gap: '6px', flexWrap: 'wrap', minWidth: 0 },
206
+ /** 行内 chips:允许收缩换行,右对齐 */
207
+ chipsRow: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: '0 1 auto', minWidth: 0, maxWidth: '100%', justifyContent: 'flex-end' },
183
208
  fileBtn: {
184
209
  padding: '5px 10px', fontSize: '12px', borderRadius: C.radiusSm, border: '1px dashed ' + C.border,
185
210
  color: C.dim, cursor: 'pointer', flex: 'none', whiteSpace: 'nowrap',
@@ -414,7 +439,7 @@ window.__ModuleLoader__.load({
414
439
  uploading === meta.key ? '上传中' : (customSounds[meta.key] ? '重新上传' : '上传'),
415
440
  h('input', {
416
441
  type: 'file',
417
- accept: '.wav,audio/wav',
442
+ accept: '.wav,.mp3,.m4a,.aac',
418
443
  style: S.fileInput,
419
444
  onChange: (e) => upload(e, meta),
420
445
  }))
@@ -430,7 +455,7 @@ window.__ModuleLoader__.load({
430
455
  ))
431
456
 
432
457
  /** 音色套选择:选中即写设置,试听按钮随即用新音色。 */
433
- const soundsetChips = h('div', { style: S.chips }, soundsets.map((set) => {
458
+ const soundsetChips = h('div', { style: S.chipsRow }, soundsets.map((set) => {
434
459
  const on = soundset === set.key
435
460
  return h('button', {
436
461
  type: 'button',
@@ -441,7 +466,7 @@ window.__ModuleLoader__.load({
441
466
  }, set.label)
442
467
  }))
443
468
 
444
- const toastChips = h('div', { style: S.chips }, kindMeta.map((meta) => {
469
+ const toastChips = h('div', { style: S.chipsRow }, kindMeta.map((meta) => {
445
470
  const on = toastKinds.includes(meta.key)
446
471
  return h('button', {
447
472
  type: 'button',
@@ -510,6 +535,16 @@ window.__ModuleLoader__.load({
510
535
  }, '保存'),
511
536
  ),
512
537
  }),
538
+ rowNode({
539
+ key: 'quietCurrent',
540
+ title: '正在看的会话完成时不响',
541
+ desc: '只对「工作完成」生效;审批、提问、出错照常响。默认关闭。',
542
+ control: toggleNode({
543
+ on: settings.quietCurrent === true,
544
+ label: '正在看的会话完成时不响',
545
+ onChange: (v) => commit({ quietCurrent: v }, v ? '正在看的会话完成时不再响' : '完成时照常响'),
546
+ }),
547
+ }),
513
548
  rowNode({
514
549
  key: 'subagents',
515
550
  title: '子会话也提醒',
@@ -642,6 +677,7 @@ window.__ModuleLoader__.load({
642
677
  const inject = ['slots']
643
678
 
644
679
  function apply (ctx) {
680
+ pluginCtx = ctx
645
681
  ctx.effect(() => {
646
682
  refresh()
647
683
  const timer = setInterval(() => { if (!document.hidden) refresh() }, POLL_MS)
package/lib/host.js CHANGED
@@ -22,14 +22,14 @@
22
22
  */
23
23
 
24
24
  import { spawn } from 'node:child_process'
25
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
25
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
26
26
  import { homedir } from 'node:os'
27
- import { dirname, isAbsolute, join, resolve } from 'node:path'
27
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
29
 
30
30
  /** 设置页写入的用户设置文件名(放在 DSH 主目录)。 */
31
31
  const SETTINGS_FILENAME = 'dsh-notify-chime.json'
32
- const VERSION = '1.4.0'
32
+ const VERSION = '1.7.0'
33
33
  /** 去抖间隔上限:填成天文数字会让提示音事实上永久失效,这里兜住。 */
34
34
  const MAX_MIN_INTERVAL_MS = 600000
35
35
  /** 通知标题前缀的长度上限(再长会被系统截断,也挤掉事件名)。 */
@@ -50,6 +50,10 @@ const SOUNDSETS = [
50
50
  const SOUNDSET_KEYS = SOUNDSETS.map((set) => set.key)
51
51
  /** 自定义音效上传上限:5 MB,足够十几秒无损音频。 */
52
52
  const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
53
+ /** 后台任务失败按会话去抖:一轮里多个任务同时失败只响一次。 */
54
+ const JOB_FAIL_WINDOW_MS = 3000
55
+ /** 客户端上报「当前正在查看的会话」后,多久内有效;过期视为没人在看。 */
56
+ const QUIET_CURRENT_MAX_AGE_MS = 15000
53
57
 
54
58
  /** DSH 主目录:profile 启动时带 DSH_HOME,裸进程回落到 ~/.dsh。 */
55
59
  function dshHome () {
@@ -90,6 +94,10 @@ const KINDS = {
90
94
  interrupted: { asset: 'interrupted.wav', label: '工作中断', body: '本轮工作已被中断' },
91
95
  error: { asset: 'error.wav', label: '工作出错', body: '本轮工作因错误结束' },
92
96
  question: { asset: 'question.wav', label: 'Agent 提问', body: 'Agent 正在等待你的回答' },
97
+ approval: { asset: 'approval.wav', label: '等待审批', body: '有操作正在等你批准' },
98
+ 'plan-review': { asset: 'plan-review.wav', label: '计划评审', body: 'Agent 提交了计划,等你评审' },
99
+ 'goal-blocked': { asset: 'goal-blocked.wav', label: '目标受阻', body: '当前目标被阻塞,需要你介入' },
100
+ 'job-failed': { asset: 'job-failed.wav', label: '后台任务失败', body: '一个后台任务执行失败' },
93
101
  }
94
102
 
95
103
  /** 默认配置(config 中未给出的字段取这里的值)。 */
@@ -99,14 +107,25 @@ const DEFAULTS = {
99
107
  minIntervalMs: 700,
100
108
  /** 是否也为 subagent / 子会话发声。 */
101
109
  includeSubagents: false,
102
- /** 音色套:default | soft | chime | retro | digital | wood | custom。 */
110
+ /**
111
+ * 音色套:default | soft | chime | retro | digital | wood | custom。
112
+ */
103
113
  soundset: 'default',
114
+ /**
115
+ * 「当前正在查看的会话完成时不响」:默认关闭。
116
+ * 只影响 finished,审批 / 提问 / 出错等一律照响。
117
+ */
118
+ quietCurrent: false,
104
119
  /** 每类事件是否发声(设置页可逐类关掉,不必整类静音)。 */
105
120
  kinds: {
106
121
  finished: true,
107
122
  interrupted: true,
108
123
  error: true,
109
124
  question: true,
125
+ approval: true,
126
+ 'plan-review': true,
127
+ 'goal-blocked': true,
128
+ 'job-failed': true,
110
129
  },
111
130
  /** 声音播放器:'auto' | 'powershell' | 'pwsh' | 'none',或播放器可执行文件的绝对路径。 */
112
131
  player: 'auto',
@@ -130,8 +149,11 @@ const DEFAULTS = {
130
149
  },
131
150
  /** 每个事件种类的声音文件;null / false 表示该类事件不发声。 */
132
151
  sounds: Object.fromEntries(Object.entries(KINDS).map(([key, meta]) => [key, join(ASSET_DIR, meta.asset)])),
133
- /** 被视为「提问」的工具名。 */
134
- questionTools: ['ask_user_question', 'exit_plan_mode'],
152
+ /**
153
+ * 视为「提问」的工具名,只决定谁触发 question 音。
154
+ * exit_plan_mode 有专属的 plan-review 分类(1.5.0 起),不再受此表控制。
155
+ */
156
+ questionTools: ['ask_user_question'],
135
157
  /** 调试用:把每次触发追加写入该文件(每行一条 JSON)。默认关闭。 */
136
158
  traceFile: null,
137
159
  }
@@ -151,6 +173,7 @@ function settingsFromOptions(options) {
151
173
  return {
152
174
  enabled: options.enabled,
153
175
  soundset: options.soundset,
176
+ quietCurrent: Boolean(options.quietCurrent),
154
177
  perKindSoundset: { ...DEFAULT_PER_KIND_SOUNDSET },
155
178
  customSounds: { ...DEFAULT_CUSTOM_SOUNDS },
156
179
  kinds: { ...options.kinds },
@@ -174,6 +197,7 @@ function normalizeSettings(raw, fallback) {
174
197
  const out = {
175
198
  enabled: fallback.enabled,
176
199
  soundset: fallback.soundset,
200
+ quietCurrent: fallback.quietCurrent,
177
201
  perKindSoundset: { ...fallback.perKindSoundset },
178
202
  customSounds: { ...fallback.customSounds },
179
203
  kinds: { ...fallback.kinds },
@@ -193,6 +217,7 @@ function normalizeSettings(raw, fallback) {
193
217
  }
194
218
  }
195
219
  if (typeof raw.soundset === 'string' && SOUNDSET_KEYS.includes(raw.soundset)) out.soundset = raw.soundset
220
+ if (typeof raw.quietCurrent === 'boolean') out.quietCurrent = raw.quietCurrent
196
221
  if (raw.perKindSoundset !== null && typeof raw.perKindSoundset === 'object' && !Array.isArray(raw.perKindSoundset)) {
197
222
  for (const key of KIND_KEYS) {
198
223
  const value = raw.perKindSoundset[key]
@@ -232,13 +257,14 @@ function applySettingsPatch(current, patch, fallback) {
232
257
  }
233
258
  if (patch.reset === true) return normalizeSettings({}, fallback)
234
259
 
235
- const known = new Set(['enabled', 'soundset', 'perKindSoundset', 'customSounds', 'kinds', 'minIntervalMs', 'includeSubagents', 'toast', 'reset'])
260
+ const known = new Set(['enabled', 'soundset', 'quietCurrent', 'perKindSoundset', 'customSounds', 'kinds', 'minIntervalMs', 'includeSubagents', 'toast', 'reset'])
236
261
  for (const key of Object.keys(patch)) {
237
262
  if (!known.has(key)) throw new Error(`未知设置项 "${key}"`)
238
263
  }
239
264
  const next = {
240
265
  enabled: current.enabled,
241
266
  soundset: current.soundset,
267
+ quietCurrent: current.quietCurrent,
242
268
  perKindSoundset: { ...current.perKindSoundset },
243
269
  customSounds: { ...current.customSounds },
244
270
  kinds: { ...current.kinds },
@@ -252,6 +278,10 @@ function applySettingsPatch(current, patch, fallback) {
252
278
  }
253
279
  next.soundset = patch.soundset
254
280
  }
281
+ if (patch.quietCurrent !== undefined) {
282
+ if (typeof patch.quietCurrent !== 'boolean') throw new TypeError('quietCurrent 必须是布尔值')
283
+ next.quietCurrent = patch.quietCurrent
284
+ }
255
285
  if (patch.perKindSoundset !== undefined) {
256
286
  if (patch.perKindSoundset === null || typeof patch.perKindSoundset !== 'object' || Array.isArray(patch.perKindSoundset)) {
257
287
  throw new TypeError('perKindSoundset 必须是对象')
@@ -399,6 +429,23 @@ function resolveSoundFile(value) {
399
429
  return isAbsolute(text) ? text : resolve(HERE, '..', text)
400
430
  }
401
431
 
432
+ /**
433
+ * 按文件头判断音频格式;不认识返回 null。
434
+ * 只认四种:WAV(RIFF/WAVE)、MP3(ID3 或 MPEG 帧同步)、M4A(ftyp)、AAC(ADTS 帧同步)。
435
+ */
436
+ function detectAudioFormat(buffer) {
437
+ if (buffer.length < 12) return null
438
+ const at = (start, end) => buffer.toString('ascii', start, end)
439
+ if (at(0, 4) === 'RIFF' && at(8, 12) === 'WAVE') return 'wav'
440
+ if (at(0, 3) === 'ID3') return 'mp3'
441
+ if (at(4, 8) === 'ftyp') return 'm4a'
442
+ if (buffer[0] === 0xFF && (buffer[1] & 0xE0) === 0xE0) {
443
+ // MPEG 音频帧:layer 位(byte1 的 2–1 位)为 0 是 ADTS/AAC,否则是 MP3
444
+ return (buffer[1] & 0x06) === 0 ? 'aac' : 'mp3'
445
+ }
446
+ return null
447
+ }
448
+
402
449
  /** PowerShell 单引号字符串转义。 */
403
450
  function quotePowerShell(text) {
404
451
  return `'${text.replace(/'/g, "''")}'`
@@ -417,14 +464,50 @@ function powerShellArgs(script) {
417
464
  return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)]
418
465
  }
419
466
 
420
- /** 解析本机可用的声音播放器;返回 { command, args(file) } 或 null。 */
421
- function resolvePlayer(preference) {
467
+ /** WPF MediaPlayer 需要 STA 线程,单独一组参数。 */
468
+ function powerShellArgsSta(script) {
469
+ return ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)]
470
+ }
471
+
472
+ /**
473
+ * MP3 / M4A 等非 WAV 格式的播放脚本:WPF MediaPlayer 走系统解码器。
474
+ * 播完才退出进程,避免提示音被截断;时长未知时最多等 5 秒,防止卡死。
475
+ */
476
+ function mediaPlayerScript(file) {
477
+ return [
478
+ "$ErrorActionPreference='Stop'",
479
+ 'Add-Type -AssemblyName PresentationCore',
480
+ '$player = New-Object System.Windows.Media.MediaPlayer',
481
+ `$player.Open([Uri]::new(${quotePowerShell(file)}))`,
482
+ 'for ($i = 0; $i -lt 40 -and -not $player.NaturalDuration.HasTimeSpan; $i++) { Start-Sleep -Milliseconds 50 }',
483
+ '$duration = 0; if ($player.NaturalDuration.HasTimeSpan) { $duration = $player.NaturalDuration.TimeSpan.TotalSeconds }',
484
+ '$player.Play()',
485
+ '$limit = if ($duration -gt 0) { [math]::Min(600, [math]::Ceiling($duration * 10) + 20) } else { 50 }',
486
+ 'for ($i = 0; $i -lt $limit; $i++) { Start-Sleep -Milliseconds 100; if ($duration -gt 0 -and $player.Position.TotalSeconds -ge $duration) { break } }',
487
+ '$player.Close()',
488
+ ].join(';')
489
+ }
490
+
491
+ /**
492
+ * 解析播放器;返回 { command, args(file) } 或 null。
493
+ *
494
+ * ext 决定 Windows 上走哪条路:WAV → SoundPlayer(延迟低),其它 → WPF MediaPlayer
495
+ * (借系统解码器播 mp3 / m4a,需要 STA)。
496
+ */
497
+ function resolvePlayer(preference, ext) {
422
498
  if (preference === 'none') return null
423
499
  const platform = process.platform
500
+ const isWav = ext === '' || ext === '.wav'
424
501
  if (platform === 'win32') {
502
+ const wantsPowerShell = preference === 'auto' || preference === 'powershell' || preference === 'pwsh'
503
+ // 非 WAV 一律走 MediaPlayer:SoundPlayer 只认 WAV。
504
+ // MediaPlayer 需要 STA,只有 Windows PowerShell 有 -STA(pwsh 7 没有),所以这里固定用 powershell.exe。
505
+ if (preference === 'wpf' || (wantsPowerShell && !isWav)) {
506
+ return { command: 'powershell.exe', args: (file) => powerShellArgsSta(mediaPlayerScript(file)) }
507
+ }
425
508
  // auto / powershell / pwsh 走 PowerShell 脚本(-EncodedCommand 免引号转义);
426
509
  // 其它值当播放器可执行文件,直接把文件路径作为唯一参数传过去。
427
- if (preference === 'auto' || preference === 'powershell' || preference === 'pwsh') {
510
+ if (wantsPowerShell) {
428
511
  const command = preference === 'pwsh' ? 'pwsh.exe' : 'powershell.exe'
429
512
  return {
430
513
  command,
@@ -438,7 +521,7 @@ function resolvePlayer(preference) {
438
521
  if (platform === 'darwin') {
439
522
  return { command: preference === 'auto' ? 'afplay' : preference, args: (file) => [file] }
440
523
  }
441
- // Linux: 依次尝试 paplay / aplay
524
+ // Linux: 依次尝试 paplay / aplay(两者都支持多种格式)
442
525
  const command = preference === 'auto' ? (existsSync('/usr/bin/paplay') ? 'paplay' : 'aplay') : preference
443
526
  return { command, args: (file) => [file] }
444
527
  }
@@ -483,10 +566,15 @@ export function apply(ctx, rawConfig) {
483
566
  const options = resolveOptions(rawConfig)
484
567
  const logger = ctx.logger('notify-sound')
485
568
 
486
- const player = resolvePlayer(options.player)
487
- if (player === null) logger.info('sound player disabled (player: none)')
569
+ if (options.player === 'none') logger.info('sound player disabled (player: none)')
488
570
  const questionTools = new Set(options.questionTools)
489
571
  const lastFired = new Map()
572
+ /** 目标阶段的上一状态:只有「非 blocked → blocked」才响一次。 */
573
+ const goalPhase = new Map()
574
+ /** 后台任务失败按会话去抖。 */
575
+ const lastJobFail = new Map()
576
+ /** 客户端上报的「当前正在查看的会话」:{ id, at };过期视为没人在看。 */
577
+ let currentView = { id: null, at: 0 }
490
578
  const tracePath = options.traceFile === null ? null : resolveSoundFile(options.traceFile)
491
579
 
492
580
  // patch 值就是初始默认值;设置页写入的值覆盖它(存在 ~/.dsh/dsh-notify-chime.json)
@@ -500,27 +588,32 @@ export function apply(ctx, rawConfig) {
500
588
  return own || settings.soundset || 'default'
501
589
  }
502
590
 
503
- /** 按当前音色套解析某类事件的音效文件;null 表示这类事件不发声。 */
591
+ /** 按当前音色套解析某类事件的音效文件;null 表示这类事件不发声。任何异常都退化为"不响"。 */
504
592
  const soundFileFor = (kind) => {
505
- const configured = options.sounds[kind]
506
- // patch 显式把该类设成 false / null:整类静音,任何音色套都不覆盖
507
- if (configured === null || configured === false) return null
508
- const set = effectiveSoundset(kind)
509
- if (set === 'custom') {
510
- const own = settings.customSounds ? settings.customSounds[kind] : null
511
- if (typeof own === 'string' && own.length > 0) {
512
- if (existsSync(own)) return own
513
- logger.warn(`custom sound missing: ${own}`)
593
+ try {
594
+ const configured = options.sounds[kind]
595
+ // patch 显式把该类设成 false / null:整类静音,任何音色套都不覆盖
596
+ if (configured === null || configured === false) return null
597
+ const set = effectiveSoundset(kind)
598
+ if (set === 'custom') {
599
+ const own = settings.customSounds ? settings.customSounds[kind] : null
600
+ if (typeof own === 'string' && own.length > 0) {
601
+ if (existsSync(own)) return own
602
+ logger.warn(`custom sound missing: ${own}`)
603
+ }
604
+ // 这类没上传、或文件被移走:回退内置默认,不静音
605
+ return resolveSoundFile(configured)
606
+ }
607
+ if (set && set !== 'default') {
608
+ const preset = join(ASSET_DIR, set, `${kind}.wav`)
609
+ if (existsSync(preset)) return preset
610
+ logger.warn(`sound set "${set}" has no ${kind}.wav, falling back to default`)
514
611
  }
515
- // 这类没上传、或文件被移走:回退内置默认,不静音
516
612
  return resolveSoundFile(configured)
613
+ } catch (error) {
614
+ logger.warn(`sound resolve failed (${kind}): ${error instanceof Error ? error.message : String(error)}`)
615
+ return null
517
616
  }
518
- if (set && set !== 'default') {
519
- const preset = join(ASSET_DIR, set, `${kind}.wav`)
520
- if (existsSync(preset)) return preset
521
- logger.warn(`sound set "${set}" has no ${kind}.wav, falling back to default`)
522
- }
523
- return resolveSoundFile(configured)
524
617
  }
525
618
 
526
619
  /** 追加一行触发记录(仅在配置了 traceFile 时)。 */
@@ -535,13 +628,16 @@ export function apply(ctx, rawConfig) {
535
628
 
536
629
  /** 播放一个音频文件(不阻塞宿主);返回实际播放的路径或 null。 */
537
630
  const playFile = (file) => {
538
- if (file === null || player === null) return null
539
- if (!existsSync(file)) {
540
- logger.warn(`sound file not found: ${file}`)
541
- return null
542
- }
631
+ if (file === null) return null
543
632
  try {
544
- const child = spawn(player.command, player.args(file), { windowsHide: true, stdio: 'ignore' })
633
+ if (!existsSync(file)) {
634
+ logger.warn(`sound file not found: ${file}`)
635
+ return null
636
+ }
637
+ // 按扩展名选播放器:.wav 走 SoundPlayer,mp3 / m4a 走 WPF MediaPlayer
638
+ const chosen = resolvePlayer(options.player, extname(file).toLowerCase())
639
+ if (chosen === null) return null
640
+ const child = spawn(chosen.command, chosen.args(file), { windowsHide: true, stdio: 'ignore' })
545
641
  child.on('error', (error) => logger.warn(`sound player failed: ${error.message}`))
546
642
  child.unref()
547
643
  return file
@@ -563,6 +659,16 @@ export function apply(ctx, rawConfig) {
563
659
  trace({ time: new Date(now).toISOString(), kind, skipped: skipReason, ...detail })
564
660
  return
565
661
  }
662
+ // 「当前正在查看的会话完成时不响」:只对 finished 生效,且要求客户端刚上报过
663
+ const viewingThis = settings.quietCurrent
664
+ && kind === 'finished'
665
+ && currentView.id !== null
666
+ && currentView.id === detail?.sessionId
667
+ && now - currentView.at < QUIET_CURRENT_MAX_AGE_MS
668
+ if (viewingThis) {
669
+ trace({ time: new Date(now).toISOString(), kind, skipped: 'quiet-current', ...detail })
670
+ return
671
+ }
566
672
  const previous = lastFired.get(kind) ?? 0
567
673
  if (now - previous < settings.minIntervalMs) {
568
674
  trace({ time: new Date(now).toISOString(), kind, skipped: 'debounce', ...detail })
@@ -623,7 +729,13 @@ export function apply(ctx, rawConfig) {
623
729
  return undefined
624
730
  }
625
731
 
626
- ctx.on('session/event', (session, event) => {
732
+ /**
733
+ * 处理一条会话事件。
734
+ *
735
+ * 这里所有字段访问都可能因 DSH 版本变化而抛错(getter、缺字段、结构变更),
736
+ * 所以调用方用 try/catch 收口——插件最坏表现是少响一次,绝不能把异常抛回宿主事件循环。
737
+ */
738
+ const handleSessionEvent = (session, event) => {
627
739
  if (!settings.includeSubagents && (session?.header?.origin === 'subagent' || session?.header?.parentSession !== undefined)) return
628
740
  if (event === null || typeof event !== 'object') return
629
741
 
@@ -634,13 +746,88 @@ export function apply(ctx, rawConfig) {
634
746
  return
635
747
  }
636
748
 
637
- if (event.type === 'tool/call' && questionTools.has(event.data?.name)) {
638
- fire('question', {
749
+ if (event.type === 'tool/call') {
750
+ const name = event.data?.name
751
+ if (typeof name !== 'string') return
752
+ // 计划评审是 exit_plan_mode 的专用语义,单独发声
753
+ if (name === 'exit_plan_mode') {
754
+ fire('plan-review', { sessionId: session?.id, title: sessionTitle(session), tool: name })
755
+ return
756
+ }
757
+ if (questionTools.has(name)) {
758
+ fire('question', {
759
+ sessionId: session?.id,
760
+ title: sessionTitle(session),
761
+ tool: name,
762
+ body: `Agent 正在等待你的回答(${name})`,
763
+ })
764
+ }
765
+ return
766
+ }
767
+
768
+ // 审批请求:DSH 0.2.2 起是独立会话事件(见 known-event-types)
769
+ if (event.type === 'approval/asked') {
770
+ const data = event.data
771
+ if (data === null || typeof data !== 'object') return
772
+ const toolName = typeof data.toolName === 'string' ? data.toolName : ''
773
+ fire('approval', {
639
774
  sessionId: session?.id,
640
775
  title: sessionTitle(session),
641
- tool: event.data.name,
642
- body: `Agent 正在等待你的回答(${event.data.name})`,
776
+ tool: toolName || undefined,
777
+ body: toolName ? `等待你批准:${toolName}` : undefined,
643
778
  })
779
+ return
780
+ }
781
+
782
+ // 目标受阻:只在「非 blocked → blocked」那一次跃迁发声
783
+ if (event.type === 'goal/change') {
784
+ const data = event.data
785
+ const goal = data !== null && typeof data === 'object' ? data.goal : undefined
786
+ const phase = goal !== null && typeof goal === 'object' && typeof goal.phase === 'string' ? goal.phase : undefined
787
+ const key = session?.id ?? '__none__'
788
+ if (phase === undefined) {
789
+ // clear 等没有 goal 的变更要清掉状态,否则同一会话之后新建目标再受阻会被误判成重复跃迁
790
+ if (data !== null && typeof data === 'object' && data.operation === 'clear') goalPhase.delete(key)
791
+ return
792
+ }
793
+ const previous = goalPhase.get(key)
794
+ goalPhase.set(key, phase)
795
+ if (phase === 'blocked' && previous !== 'blocked') {
796
+ fire('goal-blocked', {
797
+ sessionId: session?.id,
798
+ title: sessionTitle(session),
799
+ body: typeof goal.objective === 'string' && goal.objective ? `目标受阻:${goal.objective}` : undefined,
800
+ })
801
+ }
802
+ return
803
+ }
804
+
805
+ // 后台任务失败:正向断言只认 status === 'failed',并按会话去重
806
+ if (event.type === 'job/settled') {
807
+ const data = event.data
808
+ if (data === null || typeof data !== 'object') return
809
+ if (data.status !== 'failed') return
810
+ const key = session?.id ?? '__none__'
811
+ const now = Date.now()
812
+ const previous = lastJobFail.get(key) ?? 0
813
+ if (now - previous < JOB_FAIL_WINDOW_MS) {
814
+ trace({ time: new Date(now).toISOString(), kind: 'job-failed', skipped: 'job-window', sessionId: session?.id })
815
+ return
816
+ }
817
+ lastJobFail.set(key, now)
818
+ fire('job-failed', {
819
+ sessionId: session?.id,
820
+ title: sessionTitle(session),
821
+ body: typeof data.detail === 'string' && data.detail ? `后台任务失败:${data.detail}` : undefined,
822
+ })
823
+ }
824
+ }
825
+
826
+ ctx.on('session/event', (session, event) => {
827
+ try {
828
+ handleSessionEvent(session, event)
829
+ } catch (error) {
830
+ logger.warn(`session/event handler failed: ${error instanceof Error ? error.message : String(error)}`)
644
831
  }
645
832
  })
646
833
 
@@ -688,6 +875,19 @@ export function apply(ctx, rawConfig) {
688
875
  res.end(JSON.stringify(payload))
689
876
  }
690
877
 
878
+ /** 路由兜底:未捕获异常转成 500 JSON,既不让请求挂起,也不冒泡到宿主。 */
879
+ const guard = (handler) => async (req, res) => {
880
+ try {
881
+ await handler(req, res)
882
+ } catch (error) {
883
+ logger.warn(`route failed: ${error instanceof Error ? error.message : String(error)}`)
884
+ try {
885
+ if (res.headersSent) res.end()
886
+ else json(res, 500, { ok: false, error: '插件内部错误' })
887
+ } catch { /* 连接已断,忽略 */ }
888
+ }
889
+ }
890
+
691
891
  /** 读 JSON 请求体;超过 16 KB 视为异常请求。出错时已自行响应,返回 null。 */
692
892
  const readJson = async (req, res) => {
693
893
  let body = ''
@@ -706,6 +906,13 @@ export function apply(ctx, rawConfig) {
706
906
  }
707
907
  }
708
908
 
909
+ /** 从 req.url 里取查询串;没有 url(mock / 非常规请求)时返回空表。 */
910
+ const queryOf = (url) => {
911
+ const text = typeof url === 'string' ? url : ''
912
+ const at = text.indexOf('?')
913
+ return new URLSearchParams(at < 0 ? '' : text.slice(at + 1))
914
+ }
915
+
709
916
  const routes = [
710
917
  {
711
918
  kind: 'exact',
@@ -716,6 +923,9 @@ export function apply(ctx, rawConfig) {
716
923
  return
717
924
  }
718
925
  if (req.method === 'HEAD') { res.writeHead(200); res.end(); return }
926
+ // 客户端每次轮询带上「当前正在查看的会话」,供 quietCurrent 判断
927
+ const current = queryOf(req.url).get('current')
928
+ if (current) currentView = { id: current, at: Date.now() }
719
929
  json(res, 200, statePayload())
720
930
  },
721
931
  },
@@ -763,7 +973,7 @@ export function apply(ctx, rawConfig) {
763
973
  json(res, 200, { ok: false, kind, error: '这类事件已配置为不发声(patch 的 sounds 设成 false / null)' })
764
974
  return
765
975
  }
766
- if (player === null) {
976
+ if (options.player === 'none') {
767
977
  json(res, 200, { ok: false, kind, sound: file, error: '播放器被禁用(patch 的 player: none)' })
768
978
  return
769
979
  }
@@ -803,15 +1013,21 @@ export function apply(ctx, rawConfig) {
803
1013
  chunks.push(chunk)
804
1014
  }
805
1015
  const buffer = Buffer.concat(chunks)
806
- if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') {
807
- json(res, 400, { ok: false, error: '只支持 WAV 文件(播放后端 System.Media.SoundPlayer 不支持其它格式)' })
1016
+ const format = detectAudioFormat(buffer)
1017
+ if (format === null) {
1018
+ json(res, 400, { ok: false, error: '无法识别的音频格式,支持 WAV / MP3 / M4A / AAC' })
808
1019
  return
809
1020
  }
810
1021
  const dir = soundsDir()
811
- const file = join(dir, `${kind}.wav`)
1022
+ const file = join(dir, `${kind}.${format}`)
812
1023
  try {
813
1024
  mkdirSync(dir, { recursive: true })
814
1025
  writeFileSync(file, buffer)
1026
+ // 换格式时清掉同类旧文件,避免同一声效留两份
1027
+ for (const stale of ['wav', 'mp3', 'm4a', 'aac']) {
1028
+ if (stale === format) continue
1029
+ try { unlinkSync(join(dir, `${kind}.${stale}`)) } catch { /* 不存在就忽略 */ }
1030
+ }
815
1031
  } catch (error) {
816
1032
  json(res, 500, { ok: false, error: `写入失败:${error instanceof Error ? error.message : String(error)}` })
817
1033
  return
@@ -840,7 +1056,7 @@ export function apply(ctx, rawConfig) {
840
1056
  ]
841
1057
  const disposers = routes.map((route) => {
842
1058
  try {
843
- return wctx.webServer.register(route)
1059
+ return wctx.webServer.register({ ...route, handler: guard(route.handler) })
844
1060
  } catch (error) {
845
1061
  // 单条路由注册失败不该连累事件监听与其它路由
846
1062
  logger.warn(`route register failed: ${route.path}: ${error instanceof Error ? error.message : String(error)}`)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-notify-chime",
3
- "version": "1.4.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
- "description": "回合完成、中断、出错或 Agent 提问时发声,可另弹系统通知;六套音色,支持自定义 WAV。",
5
+ "description": "回合完成、中断、出错、审批或提问时发声,可另弹系统通知;六套音色,自定义音效支持 WAV / MP3 / M4A。",
6
6
  "main": "lib/host.js",
7
7
  "exports": {
8
8
  ".": "./lib/host.js",
@@ -115,6 +115,14 @@ SETS = {
115
115
  length=0.78, gain=0.40, decay=6.5),
116
116
  "question": dict(notes=[(93, 0.00, 0.20, 0.90), (93, 0.20, 0.20, 0.95), (100, 0.40, 0.42, 1.00)],
117
117
  length=0.85, gain=0.34, decay=7.5),
118
+ "approval": dict(notes=[(81, 0.00, 0.20, 0.90), (88, 0.14, 0.50, 1.00)],
119
+ length=0.75, gain=0.40, decay=6.0),
120
+ "plan-review": dict(notes=[(84, 0.00, 0.24, 0.90), (88, 0.14, 0.24, 0.95), (91, 0.28, 0.55, 1.00)],
121
+ length=0.90, gain=0.38, decay=5.5),
122
+ "goal-blocked": dict(notes=[(48, 0.00, 0.30, 1.00), (43, 0.22, 0.55, 0.90)],
123
+ length=0.85, gain=0.42, decay=5.0),
124
+ "job-failed": dict(notes=[(72, 0.00, 0.14, 1.00), (71, 0.16, 0.30, 0.90)],
125
+ length=0.50, gain=0.40, decay=8.0),
118
126
  },
119
127
  },
120
128
  # 柔和:低八度、慢起音、谐波少,适合安静环境
@@ -129,6 +137,14 @@ SETS = {
129
137
  length=0.92, gain=0.30, decay=3.8),
130
138
  "question": dict(notes=[(81, 0.00, 0.24, 0.90), (81, 0.24, 0.24, 0.95), (88, 0.48, 0.50, 1.00)],
131
139
  length=1.05, gain=0.30, decay=3.4),
140
+ "approval": dict(notes=[(69, 0.00, 0.30, 0.90), (76, 0.18, 0.60, 1.00)],
141
+ length=0.95, gain=0.30, decay=3.2),
142
+ "plan-review": dict(notes=[(72, 0.00, 0.34, 0.90), (76, 0.18, 0.34, 0.95), (79, 0.36, 0.65, 1.00)],
143
+ length=1.15, gain=0.30, decay=3.0),
144
+ "goal-blocked": dict(notes=[(40, 0.00, 0.36, 1.00), (35, 0.26, 0.60, 0.90)],
145
+ length=0.95, gain=0.30, decay=3.4),
146
+ "job-failed": dict(notes=[(64, 0.00, 0.20, 1.00), (63, 0.22, 0.40, 0.90)],
147
+ length=0.65, gain=0.30, decay=4.0),
132
148
  },
133
149
  },
134
150
  # 钟声:长尾、泛音密集、金属感
@@ -143,6 +159,14 @@ SETS = {
143
159
  length=1.20, gain=0.30, decay=2.4),
144
160
  "question": dict(notes=[(93, 0.00, 0.30, 0.90), (93, 0.25, 0.30, 0.95), (96, 0.50, 0.90, 1.00)],
145
161
  length=1.35, gain=0.30, decay=2.0),
162
+ "approval": dict(notes=[(86, 0.00, 0.45, 0.90), (93, 0.16, 0.85, 1.00)],
163
+ length=1.20, gain=0.30, decay=2.2),
164
+ "plan-review": dict(notes=[(84, 0.00, 0.50, 0.90), (88, 0.16, 0.50, 0.95), (91, 0.32, 0.90, 1.00)],
165
+ length=1.40, gain=0.30, decay=2.0),
166
+ "goal-blocked": dict(notes=[(50, 0.00, 0.60, 1.00), (45, 0.30, 0.90, 0.90)],
167
+ length=1.30, gain=0.30, decay=2.4),
168
+ "job-failed": dict(notes=[(74, 0.00, 0.40, 1.00), (73, 0.24, 0.70, 0.90)],
169
+ length=1.00, gain=0.30, decay=2.6),
146
170
  },
147
171
  },
148
172
  # 复古:方波(奇次谐波)、短促、8-bit 味
@@ -157,6 +181,14 @@ SETS = {
157
181
  length=0.38, gain=0.34, decay=10.0),
158
182
  "question": dict(notes=[(88, 0.00, 0.10, 0.90), (88, 0.12, 0.10, 0.95), (91, 0.24, 0.18, 1.00)],
159
183
  length=0.46, gain=0.34, decay=9.0),
184
+ "approval": dict(notes=[(79, 0.00, 0.10, 0.90), (84, 0.10, 0.20, 1.00)],
185
+ length=0.35, gain=0.34, decay=9.0),
186
+ "plan-review": dict(notes=[(76, 0.00, 0.10, 0.90), (79, 0.09, 0.10, 0.95), (84, 0.18, 0.18, 1.00)],
187
+ length=0.40, gain=0.34, decay=9.0),
188
+ "goal-blocked": dict(notes=[(48, 0.00, 0.12, 1.00), (43, 0.12, 0.20, 0.90)],
189
+ length=0.36, gain=0.34, decay=9.0),
190
+ "job-failed": dict(notes=[(72, 0.00, 0.09, 1.00), (71, 0.10, 0.16, 0.90)],
191
+ length=0.30, gain=0.34, decay=10.0),
160
192
  },
161
193
  },
162
194
  # 电子:极硬起音、极短衰减,像设备提示音
@@ -171,6 +203,14 @@ SETS = {
171
203
  length=0.30, gain=0.36, decay=16.0),
172
204
  "question": dict(notes=[(93, 0.00, 0.07, 0.90), (93, 0.10, 0.07, 0.95), (96, 0.20, 0.14, 1.00)],
173
205
  length=0.38, gain=0.36, decay=14.0),
206
+ "approval": dict(notes=[(83, 0.00, 0.07, 0.90), (88, 0.08, 0.14, 1.00)],
207
+ length=0.26, gain=0.36, decay=14.0),
208
+ "plan-review": dict(notes=[(81, 0.00, 0.07, 0.90), (85, 0.07, 0.07, 0.95), (88, 0.14, 0.14, 1.00)],
209
+ length=0.32, gain=0.36, decay=14.0),
210
+ "goal-blocked": dict(notes=[(53, 0.00, 0.08, 1.00), (48, 0.09, 0.16, 0.90)],
211
+ length=0.30, gain=0.36, decay=14.0),
212
+ "job-failed": dict(notes=[(74, 0.00, 0.06, 1.00), (73, 0.07, 0.12, 0.90)],
213
+ length=0.22, gain=0.36, decay=16.0),
174
214
  },
175
215
  },
176
216
  # 木质:低频、极快衰减,敲击感,不刺耳
@@ -185,6 +225,14 @@ SETS = {
185
225
  length=0.34, gain=0.42, decay=22.0),
186
226
  "question": dict(notes=[(79, 0.00, 0.09, 0.90), (79, 0.11, 0.09, 0.95), (83, 0.22, 0.14, 1.00)],
187
227
  length=0.42, gain=0.42, decay=20.0),
228
+ "approval": dict(notes=[(69, 0.00, 0.09, 0.90), (74, 0.08, 0.16, 1.00)],
229
+ length=0.30, gain=0.42, decay=20.0),
230
+ "plan-review": dict(notes=[(67, 0.00, 0.09, 0.90), (71, 0.08, 0.09, 0.95), (74, 0.16, 0.14, 1.00)],
231
+ length=0.35, gain=0.42, decay=20.0),
232
+ "goal-blocked": dict(notes=[(45, 0.00, 0.10, 1.00), (40, 0.10, 0.16, 0.90)],
233
+ length=0.30, gain=0.42, decay=20.0),
234
+ "job-failed": dict(notes=[(64, 0.00, 0.08, 1.00), (63, 0.09, 0.14, 0.90)],
235
+ length=0.26, gain=0.42, decay=20.0),
188
236
  },
189
237
  },
190
238
  }