claude-sound 0.1.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 +21 -0
- package/README.md +69 -0
- package/assets/sounds/manifest.json +42 -0
- package/assets/sounds/ring1.wav +0 -0
- package/assets/sounds/ring10.wav +0 -0
- package/assets/sounds/ring2.wav +0 -0
- package/assets/sounds/ring3.wav +0 -0
- package/assets/sounds/ring4.wav +0 -0
- package/assets/sounds/ring5.wav +0 -0
- package/assets/sounds/ring6.wav +0 -0
- package/assets/sounds/ring7.wav +0 -0
- package/assets/sounds/ring8.wav +0 -0
- package/assets/sounds/ring9.wav +0 -0
- package/package.json +28 -0
- package/src/cli.js +199 -0
- package/src/hooks.js +136 -0
- package/src/play.js +12 -0
- package/src/sounds.js +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,69 @@
|
|
|
1
|
+
# claude-sound
|
|
2
|
+
|
|
3
|
+
macOS-only CLI that configures **Claude Code Hooks** to play **bundled sounds** using `afplay`.
|
|
4
|
+
|
|
5
|
+
- Setup UI: `npx claude-sound@latest`
|
|
6
|
+
- Hook runner: `npx --yes claude-sound@latest play --event <Event> --sound <SoundId> --managed-by claude-sound`
|
|
7
|
+
|
|
8
|
+
## Install / run
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx claude-sound@latest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You’ll be prompted to choose where to write settings:
|
|
15
|
+
|
|
16
|
+
- Project (shared): `.claude/settings.json`
|
|
17
|
+
- Project (local): `.claude/settings.local.json`
|
|
18
|
+
- Global: `~/.claude/settings.json`
|
|
19
|
+
|
|
20
|
+
Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
claude-sound list-events
|
|
26
|
+
claude-sound list-sounds
|
|
27
|
+
claude-sound play --sound ring1
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What gets written
|
|
31
|
+
|
|
32
|
+
For each configured event, `claude-sound` writes a Claude hook handler like:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"hooks": {
|
|
37
|
+
"SessionStart": [
|
|
38
|
+
{
|
|
39
|
+
"matcher": "*",
|
|
40
|
+
"hooks": [
|
|
41
|
+
{
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "npx --yes claude-sound@latest play --event SessionStart --sound ring1 --managed-by claude-sound",
|
|
44
|
+
"async": true,
|
|
45
|
+
"timeout": 5
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`claude-sound` only manages hook handlers whose `command` contains `--managed-by claude-sound`.
|
|
55
|
+
|
|
56
|
+
## Uninstall / remove hooks
|
|
57
|
+
|
|
58
|
+
Run the setup again and choose **Remove all claude-sound hooks**, then **Apply**.
|
|
59
|
+
|
|
60
|
+
Or manually delete any hook handlers whose command contains:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
--managed-by claude-sound
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Notes
|
|
67
|
+
|
|
68
|
+
- macOS only (requires `afplay`).
|
|
69
|
+
- Hooks run `npx` each time the event fires. It’s simple and works everywhere, but may be slower than a local install.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "ring1",
|
|
4
|
+
"file": "assets/sounds/ring1.wav"
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"id": "ring2",
|
|
8
|
+
"file": "assets/sounds/ring2.wav"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "ring3",
|
|
12
|
+
"file": "assets/sounds/ring3.wav"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "ring4",
|
|
16
|
+
"file": "assets/sounds/ring4.wav"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "ring5",
|
|
20
|
+
"file": "assets/sounds/ring5.wav"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "ring6",
|
|
24
|
+
"file": "assets/sounds/ring6.wav"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "ring7",
|
|
28
|
+
"file": "assets/sounds/ring7.wav"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "ring8",
|
|
32
|
+
"file": "assets/sounds/ring8.wav"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "ring9",
|
|
36
|
+
"file": "assets/sounds/ring9.wav"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "ring10",
|
|
40
|
+
"file": "assets/sounds/ring10.wav"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-sound",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configure Claude Code hooks to play bundled sounds on macOS (afplay).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": ""
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"claude-sound": "./src/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"assets"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"generate:sounds": "node ./scripts/generate-sounds.mjs",
|
|
23
|
+
"prepack": "npm run generate:sounds"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@clack/prompts": "^0.11.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { intro, outro, select, isCancel, cancel, note, spinner } from '@clack/prompts';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import { playSound } from './play.js';
|
|
7
|
+
import { listSounds } from './sounds.js';
|
|
8
|
+
import {
|
|
9
|
+
HOOK_EVENTS,
|
|
10
|
+
configPathForScope,
|
|
11
|
+
readJsonIfExists,
|
|
12
|
+
writeJson,
|
|
13
|
+
getExistingManagedMappings,
|
|
14
|
+
applyMappingsToSettings
|
|
15
|
+
} from './hooks.js';
|
|
16
|
+
|
|
17
|
+
function usage(exitCode = 0) {
|
|
18
|
+
process.stdout.write(`\
|
|
19
|
+
claude-sound (macOS)\n\nUsage:\n npx claude-sound@latest Interactive hook sound setup\n claude-sound Interactive hook sound setup\n\n claude-sound play --sound <id> Play a bundled sound (uses afplay)\n claude-sound list-sounds List bundled sound ids\n claude-sound list-events List Claude hook event names\n\nOptions:\n -h, --help Show help\n\nExamples:\n npx claude-sound@latest\n npx claude-sound@latest play --sound ring1\n`);
|
|
20
|
+
process.exit(exitCode);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArg(flag) {
|
|
24
|
+
const idx = process.argv.indexOf(flag);
|
|
25
|
+
if (idx === -1) return null;
|
|
26
|
+
return process.argv[idx + 1] ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function cmdPlay() {
|
|
30
|
+
const soundId = parseArg('--sound');
|
|
31
|
+
if (!soundId) {
|
|
32
|
+
process.stderr.write('Missing --sound <id>\n');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await playSound(soundId);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
process.stderr.write(`Failed to play sound '${soundId}': ${err?.message || err}\n`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function cmdListSounds() {
|
|
45
|
+
const sounds = await listSounds();
|
|
46
|
+
for (const s of sounds) process.stdout.write(s + '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function cmdListEvents() {
|
|
50
|
+
for (const e of HOOK_EVENTS) process.stdout.write(e + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function interactiveSetup() {
|
|
54
|
+
intro('claude-sound');
|
|
55
|
+
|
|
56
|
+
const scope = await select({
|
|
57
|
+
message: 'Where do you want to write Claude Code hook settings?',
|
|
58
|
+
options: [
|
|
59
|
+
{ value: 'project', label: 'Project (shared): .claude/settings.json' },
|
|
60
|
+
{ value: 'projectLocal', label: 'Project (local): .claude/settings.local.json (gitignored)' },
|
|
61
|
+
{ value: 'global', label: 'Global: ~/.claude/settings.json' }
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (isCancel(scope)) {
|
|
66
|
+
cancel('Cancelled');
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const projectDir = process.cwd();
|
|
71
|
+
const settingsPath = configPathForScope(scope, projectDir);
|
|
72
|
+
|
|
73
|
+
const existingRes = await readJsonIfExists(settingsPath);
|
|
74
|
+
if (!existingRes.ok) {
|
|
75
|
+
cancel(`Could not read/parse JSON at ${settingsPath}`);
|
|
76
|
+
note(String(existingRes.error?.message || existingRes.error), 'Error');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let settings = existingRes.value;
|
|
81
|
+
|
|
82
|
+
// Load existing mappings we previously wrote.
|
|
83
|
+
/** @type {Record<string, string>} */
|
|
84
|
+
let mappings = getExistingManagedMappings(settings);
|
|
85
|
+
|
|
86
|
+
const sounds = await listSounds();
|
|
87
|
+
|
|
88
|
+
// main loop
|
|
89
|
+
while (true) {
|
|
90
|
+
const options = HOOK_EVENTS.map((eventName) => {
|
|
91
|
+
const soundId = mappings[eventName];
|
|
92
|
+
const enabled = Boolean(soundId);
|
|
93
|
+
return {
|
|
94
|
+
value: eventName,
|
|
95
|
+
label: `${enabled ? '[x]' : '[ ]'} ${eventName}${soundId ? ` → ${soundId}` : ''}`
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
options.push({ value: '__apply__', label: 'Apply (write settings)' });
|
|
100
|
+
options.push({ value: '__remove_all__', label: 'Remove all claude-sound hooks' });
|
|
101
|
+
options.push({ value: '__exit__', label: 'Exit (no changes)' });
|
|
102
|
+
|
|
103
|
+
const choice = await select({
|
|
104
|
+
message: `Configure hook sounds (${settingsPath})`,
|
|
105
|
+
options
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (isCancel(choice) || choice === '__exit__') {
|
|
109
|
+
cancel('No changes written');
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (choice === '__remove_all__') {
|
|
114
|
+
mappings = {};
|
|
115
|
+
note('All claude-sound mappings cleared (not written yet). Choose Apply to save.', 'Cleared');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (choice === '__apply__') {
|
|
120
|
+
const s = spinner();
|
|
121
|
+
s.start('Writing settings...');
|
|
122
|
+
settings = applyMappingsToSettings(settings, mappings);
|
|
123
|
+
await writeJson(settingsPath, settings);
|
|
124
|
+
s.stop('Done');
|
|
125
|
+
outro(`Saved hooks to ${settingsPath}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const eventName = choice;
|
|
130
|
+
|
|
131
|
+
const action = await select({
|
|
132
|
+
message: `Event: ${eventName}`,
|
|
133
|
+
options: [
|
|
134
|
+
{ value: 'enable', label: mappings[eventName] ? 'Change sound' : 'Enable & choose sound' },
|
|
135
|
+
{ value: 'disable', label: 'Disable (remove mapping)' },
|
|
136
|
+
{ value: 'back', label: 'Back' }
|
|
137
|
+
]
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (isCancel(action) || action === 'back') continue;
|
|
141
|
+
|
|
142
|
+
if (action === 'disable') {
|
|
143
|
+
delete mappings[eventName];
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const soundId = await select({
|
|
148
|
+
message: `Pick a sound for ${eventName}`,
|
|
149
|
+
options: sounds.map((id) => ({ value: id, label: id }))
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (isCancel(soundId)) continue;
|
|
153
|
+
|
|
154
|
+
// quick preview
|
|
155
|
+
try {
|
|
156
|
+
await playSound(soundId);
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore preview errors
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
mappings[eventName] = soundId;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
const args = process.argv.slice(2);
|
|
167
|
+
|
|
168
|
+
if (args.includes('-h') || args.includes('--help')) usage(0);
|
|
169
|
+
|
|
170
|
+
const cmd = args[0];
|
|
171
|
+
|
|
172
|
+
if (!cmd) {
|
|
173
|
+
await interactiveSetup();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (cmd === 'play') {
|
|
178
|
+
await cmdPlay();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (cmd === 'list-sounds') {
|
|
183
|
+
await cmdListSounds();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (cmd === 'list-events') {
|
|
188
|
+
await cmdListEvents();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
process.stderr.write(`Unknown command: ${cmd}\n`);
|
|
193
|
+
usage(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch((err) => {
|
|
197
|
+
process.stderr.write(String(err?.stack || err) + '\n');
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export const HOOK_EVENTS = [
|
|
6
|
+
'SessionStart',
|
|
7
|
+
'UserPromptSubmit',
|
|
8
|
+
'PreToolUse',
|
|
9
|
+
'PermissionRequest',
|
|
10
|
+
'PostToolUse',
|
|
11
|
+
'PostToolUseFailure',
|
|
12
|
+
'Notification',
|
|
13
|
+
'SubagentStart',
|
|
14
|
+
'SubagentStop',
|
|
15
|
+
'Stop',
|
|
16
|
+
'TeammateIdle',
|
|
17
|
+
'TaskCompleted',
|
|
18
|
+
'PreCompact',
|
|
19
|
+
'SessionEnd'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function configPathForScope(scope, projectDir) {
|
|
23
|
+
if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
|
|
24
|
+
if (scope === 'project') return path.join(projectDir, '.claude', 'settings.json');
|
|
25
|
+
if (scope === 'projectLocal') return path.join(projectDir, '.claude', 'settings.local.json');
|
|
26
|
+
throw new Error(`Unknown scope: ${scope}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readJsonIfExists(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
32
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
35
|
+
return { ok: true, value: {} };
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, error: err };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function writeJson(filePath, obj) {
|
|
42
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
43
|
+
const text = JSON.stringify(obj, null, 2) + '\n';
|
|
44
|
+
await fs.writeFile(filePath, text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MANAGED_TOKEN = '--managed-by claude-sound';
|
|
48
|
+
|
|
49
|
+
export function isManagedCommand(command) {
|
|
50
|
+
return typeof command === 'string' && command.includes(MANAGED_TOKEN);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildManagedCommand({ eventName, soundId }) {
|
|
54
|
+
// Use --yes to avoid prompts in hook context.
|
|
55
|
+
// Keep args stable so we can parse back.
|
|
56
|
+
return `npx --yes claude-sound@latest play --event ${eventName} --sound ${soundId} ${MANAGED_TOKEN}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function extractManagedSoundId(command) {
|
|
60
|
+
const m = /--sound\s+([^\s]+)/.exec(command || '');
|
|
61
|
+
return m ? m[1] : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getExistingManagedMappings(settings) {
|
|
65
|
+
/** @type {Record<string, string>} */
|
|
66
|
+
const map = {};
|
|
67
|
+
const hooks = settings?.hooks;
|
|
68
|
+
if (!hooks || typeof hooks !== 'object') return map;
|
|
69
|
+
|
|
70
|
+
for (const [eventName, groups] of Object.entries(hooks)) {
|
|
71
|
+
if (!Array.isArray(groups)) continue;
|
|
72
|
+
for (const g of groups) {
|
|
73
|
+
const handlers = g?.hooks;
|
|
74
|
+
if (!Array.isArray(handlers)) continue;
|
|
75
|
+
for (const h of handlers) {
|
|
76
|
+
const cmd = h?.command;
|
|
77
|
+
if (isManagedCommand(cmd)) {
|
|
78
|
+
const soundId = extractManagedSoundId(cmd);
|
|
79
|
+
if (soundId) map[eventName] = soundId;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function applyMappingsToSettings(settings, mappings) {
|
|
89
|
+
const out = { ...(settings || {}) };
|
|
90
|
+
out.hooks = { ...(out.hooks || {}) };
|
|
91
|
+
|
|
92
|
+
// First: remove all existing managed handlers.
|
|
93
|
+
for (const [eventName, groups] of Object.entries(out.hooks)) {
|
|
94
|
+
if (!Array.isArray(groups)) continue;
|
|
95
|
+
|
|
96
|
+
const newGroups = [];
|
|
97
|
+
for (const g of groups) {
|
|
98
|
+
const handlers = Array.isArray(g?.hooks) ? g.hooks : [];
|
|
99
|
+
const kept = handlers.filter((h) => !isManagedCommand(h?.command));
|
|
100
|
+
if (kept.length > 0) {
|
|
101
|
+
newGroups.push({ ...g, hooks: kept });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (newGroups.length > 0) out.hooks[eventName] = newGroups;
|
|
106
|
+
else delete out.hooks[eventName];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Then: add current mappings.
|
|
110
|
+
// IMPORTANT: do not clobber other user-defined hook groups for the same event.
|
|
111
|
+
for (const [eventName, soundId] of Object.entries(mappings)) {
|
|
112
|
+
if (!soundId) continue;
|
|
113
|
+
|
|
114
|
+
const handler = {
|
|
115
|
+
type: 'command',
|
|
116
|
+
command: buildManagedCommand({ eventName, soundId }),
|
|
117
|
+
async: true,
|
|
118
|
+
timeout: 5
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const group = {
|
|
122
|
+
matcher: '*',
|
|
123
|
+
hooks: [handler]
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const existingGroups = Array.isArray(out.hooks[eventName]) ? out.hooks[eventName] : [];
|
|
127
|
+
out.hooks[eventName] = [...existingGroups, group];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Clean up if hooks is now empty
|
|
131
|
+
if (out.hooks && Object.keys(out.hooks).length === 0) {
|
|
132
|
+
delete out.hooks;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return out;
|
|
136
|
+
}
|
package/src/play.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { resolveSoundPath } from './sounds.js';
|
|
3
|
+
|
|
4
|
+
export function playSound(soundId) {
|
|
5
|
+
const file = resolveSoundPath(soundId);
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
execFile('afplay', [file], (err) => {
|
|
8
|
+
if (err) reject(err);
|
|
9
|
+
else resolve();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
package/src/sounds.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
export function soundsDir() {
|
|
9
|
+
return path.resolve(__dirname, '..', 'assets', 'sounds');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function listSounds() {
|
|
13
|
+
const manifestPath = path.join(soundsDir(), 'manifest.json');
|
|
14
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
15
|
+
/** @type {{id:string,file:string}[]} */
|
|
16
|
+
const items = JSON.parse(raw);
|
|
17
|
+
return items.map((it) => it.id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveSoundPath(soundId) {
|
|
21
|
+
return path.join(soundsDir(), `${soundId}.wav`);
|
|
22
|
+
}
|