claude-sound 0.1.9 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # claude-sound
2
2
 
3
- macOS-only CLI that configures **Claude Code Hooks** to play **bundled sounds** using `afplay`.
3
+ Cross-platform CLI (macOS, Windows, Linux) that configures **Claude Code Hooks** to play **bundled sounds**.
4
4
 
5
5
  ![claude-sound CLI](assets/images/cli-installation.png)
6
6
 
@@ -19,7 +19,7 @@ You’ll be prompted to choose where to write settings:
19
19
  - Project (local): `.claude/settings.local.json`
20
20
  - Global: `~/.claude/settings.json`
21
21
 
22
- Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview.
22
+ Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview. Choose **Create my own** to generate custom text-to-speech sounds.
23
23
 
24
24
  ## Commands
25
25
 
@@ -84,7 +84,20 @@ Add `assets/sounds/order.json` to control order and display names:
84
84
  - Use full IDs (e.g. `common/baemin`). Sounds not listed append at the end.
85
85
  - Use `{ "id": "...", "label": "Display Name" }` for custom labels; otherwise the filename is shown.
86
86
 
87
+ ## Create my own (text-to-speech)
88
+
89
+ When picking a sound, choose **Create my own** to generate custom sounds from text. Enter any phrase (e.g. "Claude is ready!") and it will be turned into speech using Google Translate TTS (free, no API key). Requires network. Custom sounds are saved to `~/.claude-sound/sounds/`.
90
+
91
+ See [docs/TTS.md](docs/TTS.md) for details.
92
+
93
+ ## Platform support
94
+
95
+ | Platform | Audio player | Notes |
96
+ |----------|--------------|-------|
97
+ | **macOS** | `afplay` | Built-in, no setup needed |
98
+ | **Windows** | `ffplay`, `mpv`, `mpg123`, or PowerShell | Install [ffmpeg](https://ffmpeg.org/) (includes `ffplay`) or [mpv](https://mpv.io/) for best support. PowerShell (built-in) plays WAV only. |
99
+ | **Linux** | `ffplay`, `mpv`, `mpg123`, `aplay`, etc. | Install ffmpeg or mpv for MP3 support. |
100
+
87
101
  ## Notes
88
102
 
89
- - macOS only (requires `afplay`).
90
103
  - Hooks run `npx` each time the event fires. It’s simple and works everywhere, but may be slower than a local install.
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-sound",
3
- "version": "0.1.9",
4
- "description": "Configure Claude Code hooks to play bundled sounds on macOS (afplay).",
3
+ "version": "0.2.2",
4
+ "description": "Configure Claude Code hooks to play bundled sounds (macOS, Windows, Linux).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/beefiker/claude-sound",
7
7
  "repository": {
@@ -27,6 +27,7 @@
27
27
  "prepack": "npm run generate:sounds"
28
28
  },
29
29
  "dependencies": {
30
- "@clack/prompts": "^1.0.0"
30
+ "@clack/prompts": "^1.0.0",
31
+ "@sefinek/google-tts-api": "^2.1.11"
31
32
  }
32
33
  }
package/src/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { intro, outro, select, isCancel, cancel, note, spinner } from '@clack/prompts';
3
+ import { intro, outro, select, text, isCancel, cancel, note, spinner } from '@clack/prompts';
4
4
  import pc from 'picocolors';
5
5
  import process from 'node:process';
6
6
  import fs from 'node:fs/promises';
7
7
  import { playSound } from './play.js';
8
- import { listSounds, listSoundsGrouped, ensureSoundsLoaded } from './sounds.js';
8
+ import { listSounds, listSoundsGrouped, ensureSoundsLoaded, invalidateSoundCache } from './sounds.js';
9
+ import { generateTts } from './tts.js';
9
10
  import { selectWithSoundPreview } from './select-with-preview.js';
10
11
  import {
11
12
  HOOK_EVENTS,
@@ -18,7 +19,7 @@ import {
18
19
 
19
20
  function usage(exitCode = 0) {
20
21
  process.stdout.write(`\
21
- 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`);
22
+ claude-sound (macOS, Windows, Linux)\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\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`);
22
23
  process.exit(exitCode);
23
24
  }
24
25
 
@@ -31,7 +32,9 @@ function parseArg(flag) {
31
32
  const SOUND_GROUPS = [
32
33
  { value: 'common', label: 'Common' },
33
34
  { value: 'game', label: 'Game' },
34
- { value: 'ring', label: 'Ring' }
35
+ { value: 'ring', label: 'Ring' },
36
+ { value: 'custom', label: 'Custom (TTS)' },
37
+ { value: '__create__', label: 'Create my own (text-to-speech)' }
35
38
  ];
36
39
 
37
40
  /**
@@ -42,7 +45,11 @@ const SOUND_GROUPS = [
42
45
  */
43
46
  function formatSoundDisplay(soundId, labels) {
44
47
  const displayName = labels[soundId] ?? (soundId.includes('/') ? soundId.split('/')[1] : soundId);
45
- const group = SOUND_GROUPS.find((g) => soundId.startsWith(g.value + '/') || (g.value === 'ring' && !soundId.includes('/')));
48
+ const group = SOUND_GROUPS.find(
49
+ (g) =>
50
+ (g.value !== '__create__' && soundId.startsWith(g.value + '/')) ||
51
+ (g.value === 'ring' && !soundId.includes('/'))
52
+ );
46
53
  return group ? `${group.label} / ${displayName}` : displayName;
47
54
  }
48
55
 
@@ -179,7 +186,9 @@ async function interactiveSetup() {
179
186
  continue;
180
187
  }
181
188
 
182
- const categoryOptions = SOUND_GROUPS.filter((g) => (soundsGrouped[g.value]?.length ?? 0) > 0);
189
+ const categoryOptions = SOUND_GROUPS.filter(
190
+ (g) => g.value === '__create__' || (soundsGrouped[g.value]?.length ?? 0) > 0
191
+ );
183
192
 
184
193
  while (true) {
185
194
  const category = await select({
@@ -189,6 +198,38 @@ async function interactiveSetup() {
189
198
 
190
199
  if (isCancel(category)) break;
191
200
 
201
+ if (category === '__create__') {
202
+ const textInput = await text({
203
+ message: 'Enter text to speak (e.g. "Claude is ready!")',
204
+ placeholder: 'Claude is ready!',
205
+ validate: (v) => {
206
+ if (!v?.trim()) return 'Text cannot be empty';
207
+ if (v.length > 200) return 'Keep it under 200 characters';
208
+ return undefined;
209
+ }
210
+ });
211
+
212
+ if (isCancel(textInput)) continue;
213
+
214
+ const s = spinner();
215
+ s.start('Generating speech...');
216
+ try {
217
+ const { soundId: newSoundId } = await generateTts(textInput);
218
+ invalidateSoundCache();
219
+ const refreshed = await listSoundsGrouped();
220
+ soundsGrouped.custom = refreshed.grouped.custom;
221
+ soundLabels[newSoundId] = refreshed.labels[newSoundId] ?? textInput.trim().slice(0, 30);
222
+ s.stop('Done');
223
+ mappings[eventName] = newSoundId;
224
+ note(`Created and selected: ${newSoundId}`, 'Created');
225
+ break;
226
+ } catch (err) {
227
+ s.stop('Failed');
228
+ note(String(err?.message ?? err), 'Error');
229
+ continue;
230
+ }
231
+ }
232
+
192
233
  const ids = soundsGrouped[category] ?? [];
193
234
  const soundOptions = buildSoundOptionsForGroup(ids, soundLabels);
194
235
 
package/src/play.js CHANGED
@@ -1,6 +1,114 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile, execFileSync, spawn } from 'node:child_process';
2
+ import { platform } from 'node:os';
2
3
  import { resolveSoundPath } from './sounds.js';
3
4
 
5
+ /**
6
+ * Player config: { name, check, args }
7
+ * - name: executable name
8
+ * - check: sync function to verify availability (returns boolean)
9
+ * - args: (filePath) => string[] - args to pass to the executable
10
+ */
11
+
12
+ /**
13
+ * Check if an executable exists in PATH.
14
+ * Uses command -v (POSIX) on Unix, where.exe on Windows.
15
+ */
16
+ function findExecutable(name) {
17
+ try {
18
+ if (platform() === 'win32') {
19
+ execFileSync('where.exe', [name], { stdio: 'pipe', windowsHide: true });
20
+ } else {
21
+ execFileSync('sh', ['-c', `command -v ${name}`], { stdio: 'pipe' });
22
+ }
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** @type {Array<{ name: string; check: () => boolean; args: (f: string) => string[] }>} */
30
+ const PLAYERS = [
31
+ {
32
+ name: 'afplay',
33
+ check: () => platform() === 'darwin' && findExecutable('afplay'),
34
+ args: (f) => [f]
35
+ },
36
+ {
37
+ name: 'ffplay',
38
+ check: () => findExecutable('ffplay'),
39
+ args: (f) => ['-nodisp', '-autoexit', '-loglevel', 'quiet', f]
40
+ },
41
+ {
42
+ name: 'mpv',
43
+ check: () => findExecutable('mpv'),
44
+ args: (f) => ['--no-video', '--really-quiet', f]
45
+ },
46
+ {
47
+ name: 'mpg123',
48
+ check: () => findExecutable('mpg123'),
49
+ args: (f) => ['-q', f]
50
+ },
51
+ {
52
+ name: 'mpg321',
53
+ check: () => findExecutable('mpg321'),
54
+ args: (f) => ['-q', f]
55
+ },
56
+ {
57
+ name: 'mplayer',
58
+ check: () => findExecutable('mplayer'),
59
+ args: (f) => ['-really-quiet', '-vo', 'null', f]
60
+ },
61
+ {
62
+ name: 'aplay',
63
+ check: () => findExecutable('aplay'),
64
+ args: (f) => ['-q', f]
65
+ },
66
+ {
67
+ name: 'paplay',
68
+ check: () => findExecutable('paplay'),
69
+ args: (f) => [f]
70
+ },
71
+ {
72
+ name: 'cvlc',
73
+ check: () => findExecutable('cvlc'),
74
+ args: (f) => ['--play-and-exit', '-q', f]
75
+ },
76
+ {
77
+ name: 'powershell.exe',
78
+ check: () => platform() === 'win32',
79
+ args: (f) => {
80
+ const escaped = f.replace(/'/g, "''");
81
+ return [
82
+ '-NoProfile',
83
+ '-Command',
84
+ `(New-Object Media.SoundPlayer '${escaped}').PlaySync()`
85
+ ];
86
+ }
87
+ }
88
+ ];
89
+
90
+ /** @type {{ name: string; args: (f: string) => string[] } | null} */
91
+ let _resolvedPlayer = null;
92
+
93
+ /**
94
+ * @returns {{ name: string; args: (f: string) => string[] }}
95
+ * @throws {Error}
96
+ */
97
+ function getPlayer() {
98
+ if (_resolvedPlayer) return _resolvedPlayer;
99
+
100
+ for (const p of PLAYERS) {
101
+ if (p.check()) {
102
+ _resolvedPlayer = { name: p.name, args: p.args };
103
+ return _resolvedPlayer;
104
+ }
105
+ }
106
+
107
+ throw new Error(
108
+ 'No audio player found. On Windows/Linux, install ffmpeg (ffplay) or mpv.'
109
+ );
110
+ }
111
+
4
112
  /**
5
113
  * Play a sound and wait for it to finish.
6
114
  * @param {string} soundId
@@ -8,8 +116,9 @@ import { resolveSoundPath } from './sounds.js';
8
116
  */
9
117
  export function playSound(soundId) {
10
118
  const file = resolveSoundPath(soundId);
119
+ const { name, args } = getPlayer();
11
120
  return new Promise((resolve, reject) => {
12
- execFile('afplay', [file], (err) => {
121
+ execFile(name, args(file), { windowsHide: true }, (err) => {
13
122
  if (err) reject(err);
14
123
  else resolve();
15
124
  });
@@ -28,11 +137,17 @@ let _previewProcess = null;
28
137
  export function playSoundPreview(soundId) {
29
138
  stopPreview();
30
139
  const file = resolveSoundPath(soundId);
31
- const proc = spawn('afplay', [file], {
140
+ const { name, args } = getPlayer();
141
+
142
+ const proc = spawn(name, args(file), {
32
143
  detached: true,
33
- stdio: 'ignore'
144
+ stdio: 'ignore',
145
+ windowsHide: true
34
146
  });
35
147
  _previewProcess = proc;
148
+ proc.on('error', () => {
149
+ if (_previewProcess === proc) _previewProcess = null;
150
+ });
36
151
  proc.on('exit', () => {
37
152
  if (_previewProcess === proc) _previewProcess = null;
38
153
  });
@@ -47,7 +162,7 @@ export function stopPreview() {
47
162
  if (_previewProcess) {
48
163
  try {
49
164
  const pid = _previewProcess.pid;
50
- if (pid != null && process.platform !== 'win32') {
165
+ if (pid != null && platform() !== 'win32') {
51
166
  process.kill(-pid, 'SIGKILL');
52
167
  } else {
53
168
  _previewProcess.kill('SIGKILL');
package/src/sounds.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { customSoundsDir } from './tts.js';
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
@@ -16,9 +17,17 @@ export function soundsDir() {
16
17
  /** @type {SoundPathMap | null} */
17
18
  let _soundPathCache = null;
18
19
 
20
+ /**
21
+ * Clear the sound path cache. Call after adding custom sounds.
22
+ * @returns {void}
23
+ */
24
+ export function invalidateSoundCache() {
25
+ _soundPathCache = null;
26
+ }
27
+
19
28
  /**
20
29
  * Build a map of sound id -> absolute file path.
21
- * Discovers sounds from manifest.json and subdirs (common/, game/).
30
+ * Discovers sounds from manifest.json, subdirs (common/, game/), and custom TTS sounds.
22
31
  * @returns {Promise<SoundPathMap>}
23
32
  */
24
33
  async function buildSoundPathMap() {
@@ -57,6 +66,20 @@ async function buildSoundPathMap() {
57
66
  }
58
67
  }
59
68
 
69
+ // From custom TTS sounds (~/.claude-sound/sounds/)
70
+ const customDir = customSoundsDir();
71
+ try {
72
+ const entries = await fs.readdir(customDir, { withFileTypes: true });
73
+ for (const e of entries) {
74
+ if (e.isFile() && (e.name.endsWith('.mp3') || e.name.endsWith('.wav'))) {
75
+ const id = `custom/${path.basename(e.name, path.extname(e.name))}`;
76
+ map[id] = path.join(customDir, e.name);
77
+ }
78
+ }
79
+ } catch {
80
+ // dir missing or not readable
81
+ }
82
+
60
83
  _soundPathCache = map;
61
84
  return map;
62
85
  }
@@ -99,7 +122,7 @@ async function applyCustomOrder(grouped, labels, base) {
99
122
  }
100
123
  if (!order || typeof order !== 'object') return;
101
124
 
102
- for (const key of ['common', 'game', 'ring']) {
125
+ for (const key of ['common', 'game', 'ring', 'custom']) {
103
126
  const ids = grouped[key];
104
127
  if (!ids?.length) continue;
105
128
  const ordered = order[key];
@@ -136,13 +159,15 @@ export async function listSoundsGrouped() {
136
159
  const grouped = /** @type {GroupedSounds} */ ({
137
160
  common: [],
138
161
  game: [],
139
- ring: []
162
+ ring: [],
163
+ custom: []
140
164
  });
141
165
  const labels = /** @type {SoundLabels} */ ({});
142
166
 
143
167
  for (const id of Object.keys(map)) {
144
168
  if (id.startsWith('common/')) grouped.common.push(id);
145
169
  else if (id.startsWith('game/')) grouped.game.push(id);
170
+ else if (id.startsWith('custom/')) grouped.custom.push(id);
146
171
  else grouped.ring.push(id);
147
172
  }
148
173
 
package/src/tts.js ADDED
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import { getAudioBase64 } from '@sefinek/google-tts-api';
5
+
6
+ const CUSTOM_SOUNDS_DIR = path.join(os.homedir(), '.claude-sound', 'sounds');
7
+
8
+ /**
9
+ * Get the directory for custom TTS sounds.
10
+ * @returns {string}
11
+ */
12
+ export function customSoundsDir() {
13
+ return CUSTOM_SOUNDS_DIR;
14
+ }
15
+
16
+ /**
17
+ * Create a filesystem-safe slug from text.
18
+ * @param {string} text
19
+ * @returns {string}
20
+ */
21
+ function slugify(text) {
22
+ const trimmed = text.trim().slice(0, 40);
23
+ const slug = trimmed
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-|-$/g, '');
27
+ return slug || 'custom';
28
+ }
29
+
30
+ /**
31
+ * Generate a short hash for uniqueness.
32
+ * @param {string} s
33
+ * @returns {string}
34
+ */
35
+ function shortHash(s) {
36
+ let h = 0;
37
+ for (let i = 0; i < s.length; i++) {
38
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
39
+ }
40
+ return Math.abs(h).toString(36).slice(0, 6);
41
+ }
42
+
43
+ /**
44
+ * Generate TTS audio from text and save to custom sounds directory.
45
+ * Uses Google Translate TTS (free, no API key). Requires network.
46
+ * @param {string} text - Text to speak
47
+ * @param {object} [opts]
48
+ * @param {string} [opts.lang] - Language code (default: 'en')
49
+ * @returns {Promise<{ soundId: string; filePath: string }>}
50
+ */
51
+ export async function generateTts(text) {
52
+ const trimmed = text.trim();
53
+ if (!trimmed) {
54
+ throw new Error('Text cannot be empty');
55
+ }
56
+
57
+ const dir = customSoundsDir();
58
+ await fs.mkdir(dir, { recursive: true });
59
+
60
+ const baseSlug = slugify(trimmed);
61
+ const unique = `${baseSlug}-${shortHash(trimmed)}`;
62
+ const filePath = path.join(dir, `${unique}.mp3`);
63
+
64
+ const base64 = await getAudioBase64(trimmed, {
65
+ lang: 'en',
66
+ slow: false,
67
+ timeout: 15000
68
+ });
69
+
70
+ const buffer = Buffer.from(base64, 'base64');
71
+ await fs.writeFile(filePath, buffer);
72
+
73
+ return {
74
+ soundId: `custom/${unique}`,
75
+ filePath
76
+ };
77
+ }