oc-sounds 0.1.0 → 0.1.1

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
@@ -15,9 +15,10 @@ Once the first release is published to npm, install globally with:
15
15
  opencode2 plugin add oc-sounds
16
16
  ```
17
17
 
18
- All sounds are enabled at **100% volume** by default. No individual sound setup
19
- is required. The package includes all 17 sound files and needs no browser or
20
- sound renderer at runtime.
18
+ All sounds are enabled at **85% volume** by default. No individual sound setup
19
+ is required. You can [change the sound for any action](#customize) in your OpenCode
20
+ config. The package includes all 17 sound files and needs no browser or sound
21
+ renderer at runtime.
21
22
 
22
23
  For project-only installation, add `oc-sounds` to `plugins` in your project's
23
24
  `opencode.jsonc` instead:
@@ -28,7 +29,7 @@ For project-only installation, add `oc-sounds` to `plugins` in your project's
28
29
  "plugins": [
29
30
  {
30
31
  "package": "oc-sounds",
31
- "options": { "volume": 1 }
32
+ "options": { "volume": 0.85 }
32
33
  }
33
34
  ]
34
35
  }
@@ -67,10 +68,22 @@ Sound plays on the **machine running the OpenCode server**. For a local TUI,
67
68
  desktop app, or browser connected to a local server, that is your computer. A
68
69
  remote/headless server needs its own accessible audio output.
69
70
 
70
- Playback runs asynchronously. Concurrent actions can overlap, and no action is
71
- throttled. If a player fails, automatic mode tries the next player; if none work,
72
- the plugin logs one warning and lets the agent continue. Unloading the plugin
73
- stops its audio processes and event subscription.
71
+ Playback runs asynchronously with burst smoothing enabled by default:
72
+
73
+ - Sound starts are spaced at least **180ms** apart per plugin instance.
74
+ - Repeated copies of the same sound within **300ms** are coalesced, including
75
+ duplicate cues waiting to play. Five simultaneous commands produce one start
76
+ cue and one return cue instead of ten overlapping sounds.
77
+ - At most **three cues** wait to play. Older routine cues are dropped when the
78
+ queue fills, and routine cues older than a second expire.
79
+ - Completion, errors, permission requests, questions, and interruptions take
80
+ priority over queued routine activity. Custom sound mappings retain this
81
+ priority.
82
+
83
+ The quiet tails of longer sounds can still overlap. If a player fails, automatic
84
+ mode tries the next player; if none work, the plugin logs one warning and lets
85
+ the agent continue. Unloading stops its queued cues, audio processes, and event
86
+ subscription.
74
87
 
75
88
  ## What makes a sound?
76
89
 
@@ -81,10 +94,11 @@ hooks. The built-in tool palette is:
81
94
  | Tool action | Sound |
82
95
  | --- | --- |
83
96
  | Read files | `page` |
84
- | Glob, grep, web search | `scan` |
85
- | Fetch a URL | `droplet` |
86
- | Write, edit, patch | `toggle` |
87
- | Shell / code execution | `pulse` |
97
+ | Glob and grep | `release` |
98
+ | Web search and fetch | `arrival` |
99
+ | Write and edit | `toggle` |
100
+ | Patch a file | `arrival` |
101
+ | Shell / code execution | `tick` |
88
102
  | Delegate to a subagent | `arrival` |
89
103
  | Load a skill | `sparkle` |
90
104
  | Ask the user a question | `chime` |
@@ -95,11 +109,11 @@ Session actions also have cues. The option keys are listed below:
95
109
 
96
110
  | Option key | Action | Default |
97
111
  | --- | --- | --- |
98
- | `prompt` | Input enters the session inbox | `press` |
112
+ | `prompt` | Input enters the session inbox | muted |
99
113
  | `start` | Agent execution starts | `loading` |
100
- | `step` | Model step starts | `pulse` |
114
+ | `step` | Model step starts | muted |
101
115
  | `thinking` | Reasoning block starts | `whisper` |
102
- | `reply` | Reply block starts | `chime` |
116
+ | `reply` | Model text / progress message starts | `scan` |
103
117
  | `tool` | Fallback tool-start cue | `tick` |
104
118
  | `toolComplete` | Tool completes | `release` |
105
119
  | `toolError` | Tool fails | `error` |
@@ -119,10 +133,27 @@ Reasoning and replies sound once per block, rather than on every streamed token.
119
133
  Tool progress updates do not repeat the start cue. Public events are scoped to
120
134
  the plugin's location and repeated event IDs are deduplicated.
121
135
 
136
+ `reply` covers model text, including mid-task updates such as "I'll check the
137
+ files now." OpenCode's text-start event does not distinguish intermediate text
138
+ from the final reply, so both use `scan`. The separate `complete` cue plays
139
+ `success` when the agent finishes its execution.
140
+
141
+ OpenCode emits `prompt`, `start`, and `step` almost simultaneously for ordinary
142
+ prompts, including before any text is visible. Only `start` is audible by
143
+ default, producing one `loading` cue rather than a three-sound burst. Set
144
+ `prompt` to `"press"` or `step` to `"pulse"` in `cues` if you want those extra
145
+ lifecycle cues.
146
+
122
147
  ## Customize
123
148
 
149
+ **Every action's sound is configurable.** Use `cues` for session actions such as
150
+ thinking, replying, and completion, and `tools` for individual tool-start sounds
151
+ such as read, edit, and web fetch. Set a value to any Cuelume sound name, or `false`
152
+ to mute it. Omitted settings keep their defaults.
153
+
124
154
  Edit the existing plugin entry in `~/.config/opencode/opencode.jsonc` (global)
125
- or your project's `opencode.jsonc`. Merge it with your other settings:
155
+ or your project's `opencode.jsonc`. Merge it with your other settings. If you use
156
+ a local checkout, keep its directory path in `package`:
126
157
 
127
158
  ```jsonc
128
159
  {
@@ -132,16 +163,25 @@ or your project's `opencode.jsonc`. Merge it with your other settings:
132
163
  "package": "oc-sounds",
133
164
  "options": {
134
165
  "enabled": true,
135
- "volume": 0.35,
166
+ "volume": 0.85,
136
167
  "player": "auto",
168
+ "playback": {
169
+ "minIntervalMs": 180,
170
+ "dedupeWindowMs": 300
171
+ },
137
172
  "cues": {
173
+ "prompt": "press",
138
174
  "thinking": "whisper",
175
+ "reply": "scan",
139
176
  "complete": "ready",
140
177
  "step": false
141
178
  },
142
179
  "tools": {
143
180
  "read": "tick",
181
+ "edit": "toggle",
144
182
  "shell": "pulse",
183
+ "webfetch": "arrival",
184
+ "websearch": "arrival",
145
185
  "my_mcp_tool": "sparkle"
146
186
  }
147
187
  }
@@ -150,10 +190,24 @@ or your project's `opencode.jsonc`. Merge it with your other settings:
150
190
  }
151
191
  ```
152
192
 
193
+ This example changes reads to `tick`, completion to `ready`, and mutes model-step
194
+ cues. Change any name to choose a different sound. For all available action and
195
+ tool mappings in one file, see [`examples/opencode.json`](examples/opencode.json),
196
+ which is also included in the npm package.
197
+
198
+ Save your OpenCode config to apply the changes. If the running plugin does not
199
+ reload automatically, run `opencode2 service restart`. Changing these options
200
+ does not require rebuilding or reinstalling the plugin.
201
+
153
202
  - `enabled`: defaults to `true`. Set to `false` to disable all sounds.
154
- - `volume`: from `0` to `1`, defaults to `1` (100%). Zero disables playback.
203
+ - `volume`: from `0` to `1`, defaults to `0.85` (85%). Zero disables playback.
155
204
  - `player`: `auto`, `paplay`, `pw-play`, `afplay`, or `ffplay`. An explicit player
156
205
  disables automatic fallback to other players.
206
+ - `playback.minIntervalMs`: minimum time between sound starts, defaults to `180`.
207
+ - `playback.dedupeWindowMs`: cooldown for repeating the same sound, defaults to
208
+ `300`. Both timing values accept integers from `0` to `5000` milliseconds. Set
209
+ both to `0` for immediate, unthrottled playback. For a calmer pace, try `250`
210
+ and `500` respectively.
157
211
  - `cues`: override any action in the table, or set it to `false` to mute it.
158
212
  Setting `cues.tool` replaces the built-in starting palette for all ordinary
159
213
  tools. `question` is configured separately.
@@ -192,7 +246,7 @@ project OpenCode configuration:
192
246
  "plugins": [
193
247
  {
194
248
  "package": "/absolute/path/to/oc-sounds",
195
- "options": { "volume": 1 }
249
+ "options": { "volume": 0.85 }
196
250
  }
197
251
  ]
198
252
  }
@@ -217,8 +271,9 @@ device. Cuelume and the native offline renderer are development dependencies;
217
271
  neither is needed by the installed plugin.
218
272
 
219
273
  Tests cover the V2 plugin lifecycle, event isolation/deduplication, concurrent
220
- tools, muting, cleanup, player fallback, and bundled WAV integrity. Linux player
221
- tests use fake executables and do not play audio.
274
+ tools, burst coalescing, playback spacing and priority, bounded queues, muting,
275
+ cleanup, player fallback, and bundled WAV integrity. Scheduler tests use a fake
276
+ clock; Linux player tests use fake executables and do not play audio.
222
277
 
223
278
  ### Publish to npm
224
279
 
@@ -230,8 +285,8 @@ npm pack --dry-run
230
285
  ```
231
286
 
232
287
  The `prepack` hook builds the plugin automatically. The package contains `dist/`,
233
- `sounds/`, the README, and the license notices; development dependencies and
234
- rendering scripts are excluded.
288
+ `sounds/`, `examples/`, the README, and the license notices; development
289
+ dependencies and rendering scripts are excluded.
235
290
 
236
291
  Sign in to an npm account with publishing access, then publish:
237
292
 
package/dist/config.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export declare const sounds: readonly ["chime", "sparkle", "droplet", "bloom", "whisper", "tick", "press", "release", "toggle", "success", "error", "page", "loading", "ready", "pulse", "scan", "arrival"];
2
2
  export type SoundName = (typeof sounds)[number];
3
3
  export declare const defaultCues: {
4
- readonly prompt: "press";
4
+ readonly prompt: false;
5
5
  readonly start: "loading";
6
- readonly step: "pulse";
6
+ readonly step: false;
7
7
  readonly thinking: "whisper";
8
- readonly reply: "chime";
8
+ readonly reply: "scan";
9
9
  readonly tool: "tick";
10
10
  readonly toolComplete: "release";
11
11
  readonly toolError: "error";
@@ -24,10 +24,19 @@ export declare const defaultCues: {
24
24
  export type Action = keyof typeof defaultCues;
25
25
  export declare const defaultToolCues: Record<string, SoundName>;
26
26
  export type PlayerName = "auto" | "paplay" | "pw-play" | "afplay" | "ffplay";
27
+ export declare const defaultPlayback: {
28
+ minIntervalMs: number;
29
+ dedupeWindowMs: number;
30
+ };
31
+ export interface PlaybackOptions {
32
+ minIntervalMs: number;
33
+ dedupeWindowMs: number;
34
+ }
27
35
  export interface Options {
28
36
  enabled: boolean;
29
37
  volume: number;
30
38
  player: PlayerName;
39
+ playback: PlaybackOptions;
31
40
  cues: Record<Action, SoundName | false>;
32
41
  tools: Record<string, SoundName | false>;
33
42
  }
package/dist/config.js CHANGED
@@ -4,11 +4,11 @@ export const sounds = [
4
4
  "pulse", "scan", "arrival",
5
5
  ];
6
6
  export const defaultCues = {
7
- prompt: "press",
7
+ prompt: false,
8
8
  start: "loading",
9
- step: "pulse",
9
+ step: false,
10
10
  thinking: "whisper",
11
- reply: "chime",
11
+ reply: "scan",
12
12
  tool: "tick",
13
13
  toolComplete: "release",
14
14
  toolError: "error",
@@ -26,21 +26,22 @@ export const defaultCues = {
26
26
  };
27
27
  export const defaultToolCues = {
28
28
  read: "page",
29
- glob: "scan",
30
- grep: "scan",
31
- websearch: "scan",
32
- webfetch: "droplet",
29
+ glob: "release",
30
+ grep: "release",
31
+ websearch: "arrival",
32
+ webfetch: "arrival",
33
33
  write: "toggle",
34
34
  edit: "toggle",
35
- patch: "toggle",
36
- apply_patch: "toggle",
37
- shell: "pulse",
38
- bash: "pulse",
39
- execute: "pulse",
35
+ patch: "arrival",
36
+ apply_patch: "arrival",
37
+ shell: "tick",
38
+ bash: "tick",
39
+ execute: "tick",
40
40
  subagent: "arrival",
41
41
  task: "arrival",
42
42
  skill: "sparkle",
43
43
  };
44
+ export const defaultPlayback = { minIntervalMs: 180, dedupeWindowMs: 300 };
44
45
  function object(value) {
45
46
  if (value && typeof value === "object" && !Array.isArray(value)) {
46
47
  return value;
@@ -55,14 +56,14 @@ function cue(value) {
55
56
  export function parseOptions(input) {
56
57
  const raw = object(input);
57
58
  for (const key of Object.keys(raw)) {
58
- if (!["enabled", "volume", "player", "cues", "tools"].includes(key)) {
59
+ if (!["enabled", "volume", "player", "playback", "cues", "tools"].includes(key)) {
59
60
  throw new Error(`oc-sounds: unknown option ${key}`);
60
61
  }
61
62
  }
62
63
  const enabled = raw.enabled ?? true;
63
64
  if (typeof enabled !== "boolean")
64
65
  throw new Error("oc-sounds: enabled must be a boolean");
65
- const volume = raw.volume ?? 1;
66
+ const volume = raw.volume ?? 0.85;
66
67
  if (typeof volume !== "number" || !Number.isFinite(volume) || volume < 0 || volume > 1) {
67
68
  throw new Error("oc-sounds: volume must be between 0 and 1");
68
69
  }
@@ -79,5 +80,14 @@ export function parseOptions(input) {
79
80
  const tools = Object.create(null);
80
81
  for (const [key, value] of Object.entries(object(raw.tools ?? {})))
81
82
  tools[key] = cue(value);
82
- return { enabled, volume, player: player, cues, tools };
83
+ const playback = { ...defaultPlayback };
84
+ for (const [key, value] of Object.entries(object(raw.playback ?? {}))) {
85
+ if (!Object.hasOwn(playback, key))
86
+ throw new Error(`oc-sounds: unknown playback option ${key}`);
87
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 5000) {
88
+ throw new Error(`oc-sounds: playback.${key} must be an integer between 0 and 5000 milliseconds`);
89
+ }
90
+ playback[key] = value;
91
+ }
92
+ return { enabled, volume, player: player, playback, cues, tools };
83
93
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
2
  import { type Action, type Options } from "./config.js";
3
3
  import { type Player } from "./player.js";
4
- export { defaultCues, defaultToolCues, sounds } from "./config.js";
5
- export type { Action, Options, SoundName } from "./config.js";
4
+ export { defaultCues, defaultToolCues, defaultPlayback, sounds } from "./config.js";
5
+ export type { Action, Options, PlaybackOptions, SoundName } from "./config.js";
6
6
  type Event = ReturnType<Plugin.Context["event"]["subscribe"]> extends AsyncIterable<infer E> ? E : never;
7
7
  export declare function actionForEvent(event: Event): Action | undefined;
8
8
  export declare function setup(ctx: Plugin.Context, makePlayer?: (selection: Options["player"], volume: number) => Player): Promise<Plugin.Cleanup | void>;
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ import { resolve } from "node:path";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import { defaultToolCues, parseOptions } from "./config.js";
5
5
  import { createPlayer } from "./player.js";
6
- export { defaultCues, defaultToolCues, sounds } from "./config.js";
6
+ import { schedulePlayer } from "./scheduler.js";
7
+ export { defaultCues, defaultToolCues, defaultPlayback, sounds } from "./config.js";
7
8
  const eventActions = {
8
9
  "session.inbox.enqueued": "prompt",
9
10
  "session.execution.started": "start",
@@ -25,12 +26,15 @@ const eventActions = {
25
26
  export function actionForEvent(event) {
26
27
  return eventActions[event.type];
27
28
  }
29
+ const importantActions = new Set([
30
+ "complete", "error", "toolError", "permission", "question", "interrupt",
31
+ ]);
28
32
  // Exported separately to exercise the actual plugin lifecycle with a fake player.
29
33
  export async function setup(ctx, makePlayer = createPlayer) {
30
34
  const options = parseOptions(ctx.options);
31
35
  if (!options.enabled || options.volume === 0)
32
36
  return;
33
- const player = makePlayer(options.player, options.volume);
37
+ const player = schedulePlayer(makePlayer(options.player, options.volume), options.playback);
34
38
  const controller = new AbortController();
35
39
  const registrations = [];
36
40
  const emit = (action, tool) => {
@@ -51,7 +55,7 @@ export async function setup(ctx, makePlayer = createPlayer) {
51
55
  }
52
56
  }
53
57
  if (sound)
54
- player.play(sound);
58
+ player.play(sound, importantActions.has(action));
55
59
  };
56
60
  const cleanup = async () => {
57
61
  controller.abort();
package/dist/player.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { PlayerName, SoundName } from "./config.js";
2
2
  type Backend = Exclude<PlayerName, "auto">;
3
3
  export interface Player {
4
- play(sound: SoundName): void;
4
+ play(sound: SoundName, important?: boolean): void;
5
5
  dispose(): void;
6
6
  }
7
7
  export declare function playerArgs(player: Backend, file: string, volume: number): string[];
@@ -0,0 +1,4 @@
1
+ import type { PlaybackOptions } from "./config.js";
2
+ import type { Player } from "./player.js";
3
+ /** One queue per plugin instance, shared by hooks and the session event stream. */
4
+ export declare function schedulePlayer(player: Player, options: PlaybackOptions, now?: () => number): Player;
@@ -0,0 +1,66 @@
1
+ const MAX_PENDING = 3;
2
+ const MAX_AGE_MS = 1000;
3
+ /** One queue per plugin instance, shared by hooks and the session event stream. */
4
+ export function schedulePlayer(player, options, now = () => performance.now()) {
5
+ let queue = [];
6
+ const recent = new Map();
7
+ let lastStart = -Infinity;
8
+ let timer;
9
+ let disposed = false;
10
+ function pump() {
11
+ if (disposed || timer)
12
+ return;
13
+ const time = now();
14
+ queue = queue.filter((item) => item.important || time - item.time <= MAX_AGE_MS);
15
+ if (!queue.length)
16
+ return;
17
+ const wait = options.minIntervalMs - (time - lastStart);
18
+ if (wait > 0) {
19
+ timer = setTimeout(() => {
20
+ timer = undefined;
21
+ pump();
22
+ }, Math.ceil(wait));
23
+ timer.unref();
24
+ return;
25
+ }
26
+ const item = queue.shift();
27
+ lastStart = time;
28
+ recent.set(item.sound, { time, important: item.important });
29
+ player.play(item.sound, item.important);
30
+ pump();
31
+ }
32
+ return {
33
+ play(sound, important = false) {
34
+ if (disposed)
35
+ return;
36
+ const time = now();
37
+ const previous = recent.get(sound);
38
+ if (previous && time - previous.time < options.dedupeWindowMs
39
+ && (!important || previous.important))
40
+ return;
41
+ // Attention/finish cues replace stale routine work instead of waiting
42
+ // behind a flood of tools. Priority follows the action, not its sound.
43
+ if (important)
44
+ queue = queue.filter((item) => item.important);
45
+ else if (queue.some((item) => item.important))
46
+ return;
47
+ if (queue.some((item) => item.sound === sound))
48
+ return;
49
+ if (queue.length >= MAX_PENDING)
50
+ queue.shift();
51
+ queue.push({ sound, important, time });
52
+ pump();
53
+ },
54
+ dispose() {
55
+ if (disposed)
56
+ return;
57
+ disposed = true;
58
+ if (timer)
59
+ clearTimeout(timer);
60
+ timer = undefined;
61
+ queue = [];
62
+ recent.clear();
63
+ player.dispose();
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "plugins": [
4
+ {
5
+ "package": "oc-sounds",
6
+ "options": {
7
+ "enabled": true,
8
+ "volume": 0.85,
9
+ "player": "auto",
10
+ "playback": {
11
+ "minIntervalMs": 180,
12
+ "dedupeWindowMs": 300
13
+ },
14
+ "cues": {
15
+ "prompt": false,
16
+ "start": "loading",
17
+ "step": false,
18
+ "thinking": "whisper",
19
+ "reply": "scan",
20
+ "tool": "tick",
21
+ "toolComplete": "release",
22
+ "toolError": "error",
23
+ "permission": "bloom",
24
+ "permissionReply": "toggle",
25
+ "question": "chime",
26
+ "retry": "scan",
27
+ "complete": "success",
28
+ "error": "error",
29
+ "interrupt": "droplet",
30
+ "compact": "page",
31
+ "compactComplete": "ready",
32
+ "skill": "sparkle",
33
+ "agent": "arrival"
34
+ },
35
+ "tools": {
36
+ "read": "page",
37
+ "glob": "release",
38
+ "grep": "release",
39
+ "websearch": "arrival",
40
+ "webfetch": "arrival",
41
+ "write": "toggle",
42
+ "edit": "toggle",
43
+ "patch": "arrival",
44
+ "apply_patch": "arrival",
45
+ "shell": "tick",
46
+ "bash": "tick",
47
+ "execute": "tick",
48
+ "subagent": "arrival",
49
+ "task": "arrival",
50
+ "skill": "sparkle"
51
+ }
52
+ }
53
+ }
54
+ ]
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-sounds",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Cuelume sound feedback for OpenCode V2 agent actions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./dist/index.js"
11
11
  },
12
- "files": ["dist", "sounds", "THIRD_PARTY_NOTICES.md"],
12
+ "files": ["dist", "sounds", "examples", "THIRD_PARTY_NOTICES.md"],
13
13
  "engines": { "node": ">=22" },
14
14
  "scripts": {
15
15
  "build": "tsc",